prizmkit 1.1.150 → 1.1.152

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.150",
3
- "bundledAt": "2026-07-25T15:41:05.690Z",
4
- "bundledFrom": "a759b38"
2
+ "frameworkVersion": "1.1.152",
3
+ "bundledAt": "2026-07-26T05:11:00.580Z",
4
+ "bundledFrom": "4db7f74"
5
5
  }
@@ -14,6 +14,77 @@ from dataclasses import dataclass, field
14
14
  from pathlib import Path
15
15
 
16
16
 
17
+ @dataclass(frozen=True)
18
+ class InterruptionDetails:
19
+ """Diagnostic-only description of an interruption received by the runtime."""
20
+
21
+ trigger: str
22
+ signal_number: int | None
23
+ signal_name: str
24
+ signal_description: str
25
+ signal_exact: bool
26
+
27
+ @property
28
+ def signal_label(self) -> str:
29
+ """Return a concise platform signal label for human-readable logs."""
30
+ if self.signal_number is None:
31
+ return "unavailable"
32
+ suffix = "" if self.signal_exact else " (inferred)"
33
+ return f"{self.signal_name}({self.signal_number}){suffix}"
34
+
35
+
36
+ class RuntimeInterruption(KeyboardInterrupt):
37
+ """KeyboardInterrupt-compatible carrier for an explicitly observed platform signal."""
38
+
39
+ def __init__(self, signal_number: int) -> None:
40
+ self.signal_number = signal_number
41
+ super().__init__(f"runtime interrupted by {_signal_name(signal_number)}({signal_number})")
42
+
43
+
44
+ def _signal_name(signal_number: int) -> str:
45
+ try:
46
+ return signal.Signals(signal_number).name
47
+ except (TypeError, ValueError):
48
+ return f"SIGNAL_{signal_number}"
49
+
50
+
51
+ def _signal_description(signal_number: int) -> str:
52
+ try:
53
+ return signal.strsignal(signal_number) or "unavailable"
54
+ except (AttributeError, TypeError, ValueError):
55
+ return "unavailable"
56
+
57
+
58
+ def describe_interruption(interruption: BaseException) -> InterruptionDetails:
59
+ """Describe an interruption without changing retry, task, or session state."""
60
+ if isinstance(interruption, RuntimeInterruption):
61
+ signal_number = interruption.signal_number
62
+ return InterruptionDetails(
63
+ trigger="platform_signal",
64
+ signal_number=signal_number,
65
+ signal_name=_signal_name(signal_number),
66
+ signal_description=_signal_description(signal_number),
67
+ signal_exact=True,
68
+ )
69
+
70
+ signal_number = getattr(signal, "SIGINT", None)
71
+ if isinstance(signal_number, int):
72
+ return InterruptionDetails(
73
+ trigger="keyboard_interrupt",
74
+ signal_number=signal_number,
75
+ signal_name=_signal_name(signal_number),
76
+ signal_description=_signal_description(signal_number),
77
+ signal_exact=False,
78
+ )
79
+ return InterruptionDetails(
80
+ trigger="keyboard_interrupt",
81
+ signal_number=None,
82
+ signal_name="unavailable",
83
+ signal_description="unavailable",
84
+ signal_exact=False,
85
+ )
86
+
87
+
17
88
  @dataclass(frozen=True)
18
89
  class ProcessSpec:
19
90
  """Portable process launch specification for runtime porting."""
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import signal
7
8
  import sys
8
9
  import threading
@@ -31,7 +32,7 @@ from .gitops import (
31
32
  worktree_runtime_context,
32
33
  worktree_save_wip,
33
34
  )
34
- from .processes import shield_interruption_signals
35
+ from .processes import RuntimeInterruption, describe_interruption, shield_interruption_signals
35
36
  from .runner_bookkeeping import (
36
37
  commit_final_bookkeeping,
37
38
  commit_pre_branch_bookkeeping,
@@ -56,9 +57,9 @@ from .task_checkout import (
56
57
  )
57
58
 
58
59
 
59
- def _interrupt_from_sigterm(_signal_number, _frame) -> None:
60
+ def _interrupt_from_sigterm(signal_number, _frame) -> None:
60
61
  """Translate daemon SIGTERM into the existing fail-closed interrupt path."""
61
- raise KeyboardInterrupt
62
+ raise RuntimeInterruption(signal_number)
62
63
 
63
64
 
64
65
  @contextmanager
@@ -517,6 +518,10 @@ def _process_item_locked(
517
518
  use_worktree = _use_worktree_for_family(env, family)
518
519
  worktree_path = None
519
520
  execution_root = paths.project_root
521
+ session_id = ""
522
+ session_paths: SessionPaths | None = None
523
+ retry_count: int | None = None
524
+ infra_error_count: int | None = None
520
525
 
521
526
  try:
522
527
  preflight = commit_preflight_ready_changes(paths.project_root, item_id)
@@ -792,7 +797,25 @@ def _process_item_locked(
792
797
  worktree_runtime,
793
798
  classification_fatal_error_code=classification.fatal_error_code,
794
799
  )
795
- except KeyboardInterrupt:
800
+ except KeyboardInterrupt as interruption:
801
+ try:
802
+ _emit_task_interruption_diagnostics(
803
+ interruption,
804
+ family=family,
805
+ invocation=invocation,
806
+ item_id=item_id,
807
+ session_id=session_id,
808
+ session_paths=session_paths,
809
+ retry_count=retry_count,
810
+ infra_error_count=infra_error_count,
811
+ branch_name=branch_name,
812
+ base_branch=base_branch,
813
+ )
814
+ except Exception as diagnostic_error:
815
+ _emit_warning(
816
+ f"Interrupt diagnostic collection failed ({type(diagnostic_error).__name__}); "
817
+ "continuing existing WIP preservation and checkout recovery behavior"
818
+ )
796
819
  with shield_interruption_signals():
797
820
  _cleanup_interrupted_task(
798
821
  item_id=item_id,
@@ -804,6 +827,48 @@ def _process_item_locked(
804
827
  raise
805
828
 
806
829
 
830
+ def _emit_task_interruption_diagnostics(
831
+ interruption: KeyboardInterrupt,
832
+ *,
833
+ family: RunnerFamily,
834
+ invocation: RunnerInvocation,
835
+ item_id: str,
836
+ session_id: str,
837
+ session_paths: SessionPaths | None,
838
+ retry_count: int | None,
839
+ infra_error_count: int | None,
840
+ branch_name: str,
841
+ base_branch: str,
842
+ ) -> None:
843
+ """Emit task-level interruption context without mutating pipeline state."""
844
+ details = describe_interruption(interruption)
845
+ progress = _progress_data(session_paths.progress_json) if session_paths is not None else {}
846
+ phase = str(progress.get("current_phase") or "unavailable")
847
+ tool = str(progress.get("current_tool") or "unavailable")
848
+ session_text = session_id or "not-started"
849
+ code_retry = "unavailable" if retry_count is None else str(retry_count)
850
+ infra_retry = "unavailable" if infra_error_count is None else str(infra_error_count)
851
+
852
+ _emit_warning(
853
+ "Interrupt diagnostics: "
854
+ f"trigger={details.trigger}; platform_signal={details.signal_label}; "
855
+ f"description={details.signal_description}; runner_pid={os.getpid()}; "
856
+ f"parent_pid={os.getppid()}; sender_pid=unavailable"
857
+ )
858
+ _emit_warning(
859
+ f"Interrupt context: task={item_id}; task_type={family.task_type}; session={session_text}; "
860
+ f"phase={phase}; tool={tool}; branch={branch_name}; base={base_branch}"
861
+ )
862
+ _emit_warning(
863
+ f"Retry accounting unchanged: code={code_retry}/{_max_retries(invocation)}; "
864
+ f"infrastructure={infra_retry}/{invocation.max_infra_retries}"
865
+ )
866
+ _emit_warning(
867
+ "No session classification or task-status update will be written by the interruption path; "
868
+ "existing WIP preservation and checkout recovery behavior remains unchanged"
869
+ )
870
+
871
+
807
872
  def _cleanup_interrupted_task(
808
873
  *,
809
874
  item_id: str,
@@ -20,7 +20,15 @@ from pathlib import Path
20
20
  from .checkpoint_state import load_checkpoint_state
21
21
  from .heartbeat import HeartbeatConfig, HeartbeatMonitor, HeartbeatState, observe_heartbeat_state, write_stale_marker
22
22
  from .launch_profiles import HeadlessLaunchProfile
23
- from .processes import ProcessHandle, ProcessSpec, launch_process, shield_interruption_signals, terminate_process_tree
23
+ from .processes import (
24
+ ProcessHandle,
25
+ ProcessSpec,
26
+ ProcessTerminationResult,
27
+ describe_interruption,
28
+ launch_process,
29
+ shield_interruption_signals,
30
+ terminate_process_tree,
31
+ )
24
32
  from .status import ProgressSummary
25
33
 
26
34
 
@@ -530,6 +538,98 @@ def _emit_live_line(message: str) -> None:
530
538
  print(message, file=sys.stderr, flush=True)
531
539
 
532
540
 
541
+ def _emit_interrupt_line(message: str) -> None:
542
+ """Best-effort console diagnostics that must never block interruption cleanup."""
543
+ try:
544
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
545
+ _emit_live_line(f"[prizmkit] INTERRUPT {timestamp} | {message}")
546
+ except OSError:
547
+ pass
548
+
549
+
550
+ def _process_group_label(pid: int | None) -> str:
551
+ if pid is None or os.name == "nt":
552
+ return "unavailable"
553
+ try:
554
+ return str(os.getpgid(pid))
555
+ except OSError:
556
+ return "unavailable"
557
+
558
+
559
+ def _diagnostic_int(value: object) -> int:
560
+ try:
561
+ return int(value or 0)
562
+ except (TypeError, ValueError):
563
+ return 0
564
+
565
+
566
+ def _emit_ai_interruption_received(
567
+ interruption: KeyboardInterrupt,
568
+ *,
569
+ command: AISessionCommand,
570
+ handle: ProcessHandle,
571
+ started_epoch: float,
572
+ session_log: Path,
573
+ progress_path: Path | None,
574
+ checkpoint_path: Path | None,
575
+ heartbeat_state: HeartbeatState | None,
576
+ ) -> None:
577
+ """Emit detailed AI-process context without writing session or task state."""
578
+ details = describe_interruption(interruption)
579
+ progress = _read_json_object(progress_path)
580
+ checkpoint_phase = _checkpoint_phase_label(checkpoint_path)
581
+ phase = checkpoint_phase or str(progress.get("current_phase") or "unavailable")
582
+ tool = str(progress.get("current_tool") or "unavailable")
583
+ messages = _diagnostic_int(progress.get("message_count"))
584
+ tool_calls = _diagnostic_int(progress.get("total_tool_calls"))
585
+ active_subagents = _diagnostic_int(progress.get("active_subagent_count"))
586
+ elapsed = _format_elapsed(time.time() - started_epoch)
587
+ heartbeat_details = heartbeat_state.details if heartbeat_state is not None else "unavailable"
588
+ heartbeat_stale = heartbeat_state.stale if heartbeat_state is not None else "unavailable"
589
+ heartbeat_age = (
590
+ _format_stale_minutes(heartbeat_state.stale_seconds)
591
+ if heartbeat_state is not None
592
+ else "unavailable"
593
+ )
594
+ log_size = heartbeat_state.log_size if heartbeat_state is not None else 0
595
+ if heartbeat_state is None:
596
+ try:
597
+ log_size = session_log.stat().st_size
598
+ except OSError:
599
+ log_size = 0
600
+
601
+ _emit_interrupt_line(
602
+ f"received trigger={details.trigger}; platform_signal={details.signal_label}; "
603
+ f"description={details.signal_description}; sender_pid=unavailable"
604
+ )
605
+ _emit_interrupt_line(
606
+ f"process runner_pid={os.getpid()}; parent_pid={os.getppid()}; ai_pid={handle.pid}; "
607
+ f"ai_process_group={_process_group_label(handle.pid)}; provider={command.provider}"
608
+ )
609
+ _emit_interrupt_line(
610
+ f"session elapsed={elapsed}; phase={phase}; tool={tool}; messages={messages}; "
611
+ f"tool_calls={tool_calls}; active_subagents={active_subagents}; log_size={_format_bytes(log_size)}"
612
+ )
613
+ _emit_interrupt_line(
614
+ f"artifacts session_log={session_log}; progress={progress_path or 'unavailable'}; "
615
+ f"checkpoint={checkpoint_path or 'unavailable'}"
616
+ )
617
+ _emit_interrupt_line(
618
+ f"heartbeat details={heartbeat_details}; stale={heartbeat_stale}; age={heartbeat_age}"
619
+ )
620
+ _emit_interrupt_line(
621
+ "terminating the AI process tree; classification and retry accounting have not run for this AI session"
622
+ )
623
+
624
+
625
+ def _emit_ai_interruption_termination(result: ProcessTerminationResult) -> None:
626
+ detail_text = ",".join(result.details) if result.details else "none"
627
+ _emit_interrupt_line(
628
+ f"process-tree termination completed pid={result.pid}; terminated={result.terminated}; "
629
+ f"forced={result.forced}; return_code={result.return_code}; details={detail_text}"
630
+ )
631
+
632
+
533
633
  def _emit_live_output(line: str) -> None:
534
634
  """Forward AI CLI output for verbose foreground runs."""
535
635
  sys.stdout.write(line)
@@ -982,9 +1082,31 @@ class AISessionLauncher:
982
1082
  stale_threshold_seconds=stale_threshold_seconds,
983
1083
  )
984
1084
  last_progress_emit = now
985
- except KeyboardInterrupt:
1085
+ except KeyboardInterrupt as interruption:
1086
+ monitor_state = heartbeat.state if heartbeat is not None else display_heartbeat_state
1087
+ try:
1088
+ _emit_ai_interruption_received(
1089
+ interruption,
1090
+ command=command,
1091
+ handle=handle,
1092
+ started_epoch=started,
1093
+ session_log=session_log,
1094
+ progress_path=self.config.progress_path,
1095
+ checkpoint_path=self.config.checkpoint_path,
1096
+ heartbeat_state=monitor_state,
1097
+ )
1098
+ except Exception as diagnostic_error:
1099
+ _emit_interrupt_line(
1100
+ f"diagnostic collection failed error={type(diagnostic_error).__name__}; "
1101
+ "continuing existing process-tree cleanup"
1102
+ )
986
1103
  with shield_interruption_signals():
987
- terminate_process_tree(handle, "interrupted", grace_seconds=self.config.stale_kill_grace_seconds)
1104
+ termination = terminate_process_tree(
1105
+ handle,
1106
+ "interrupted",
1107
+ grace_seconds=self.config.stale_kill_grace_seconds,
1108
+ )
1109
+ _emit_ai_interruption_termination(termination)
988
1110
  raise
989
1111
  if self.config.live_output and (not self.config.verbose or self.config.suppress_stream_output):
990
1112
  final_heartbeat_state = _display_heartbeat_state(
@@ -586,11 +586,20 @@ def action_update(args, bug_list_path, state_dir):
586
586
  error_out("Failed to update .prizmkit/plans/bug-fix-list.json: {}".format(err))
587
587
  return
588
588
  elif session_status == "context_overflow":
589
- # Context overflow is continuation state, not code or infra retry state.
589
+ # Preserve resumable context metadata, but every relaunched AI session
590
+ # must consume the existing bounded retry budget.
590
591
  set_context_overflow_continuation_metadata(bs, args, session_id)
591
- new_status = "in_progress"
592
- if current_list_status != "in_progress":
593
- err = update_bug_in_list(bug_list_path, bug_id, "in_progress")
592
+ bs["retry_count"] = bs.get("retry_count", 0) + 1
593
+ if bs["retry_count"] >= max_retries:
594
+ new_status = "failed"
595
+ bs["continuation_pending"] = False
596
+ bs["needs_attention"] = True
597
+ if session_id:
598
+ bs["last_failed_session_id"] = session_id
599
+ else:
600
+ new_status = "in_progress"
601
+ if current_list_status != new_status:
602
+ err = update_bug_in_list(bug_list_path, bug_id, new_status)
594
603
  if err:
595
604
  error_out("Failed to update .prizmkit/plans/bug-fix-list.json: {}".format(err))
596
605
  return
@@ -689,7 +698,7 @@ def action_update(args, bug_list_path, state_dir):
689
698
  summary.update(continuation_metadata_summary(bs))
690
699
  summary["artifacts_preserved"] = True
691
700
  elif session_status == "context_overflow":
692
- summary["restart_policy"] = "context_overflow_continuation"
701
+ summary["restart_policy"] = "context_overflow_continuation" if new_status == "in_progress" else "needs_attention"
693
702
  summary.update(continuation_metadata_summary(bs))
694
703
  summary["artifacts_preserved"] = True
695
704
  elif session_status == "workflow_skipped":
@@ -1014,11 +1014,20 @@ def action_update(args, feature_list_path, state_dir):
1014
1014
  error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
1015
1015
  return
1016
1016
  elif session_status == "context_overflow":
1017
- # Context overflow is continuation state, not code or infra retry state.
1017
+ # Preserve resumable context metadata, but every relaunched AI session
1018
+ # must consume the existing bounded retry budget.
1018
1019
  set_context_overflow_continuation_metadata(fs, args, session_id)
1019
- new_status = "in_progress"
1020
- if current_list_status != "in_progress":
1021
- err = update_feature_in_list(feature_list_path, feature_id, "in_progress")
1020
+ fs["retry_count"] = fs.get("retry_count", 0) + 1
1021
+ if fs["retry_count"] >= max_retries:
1022
+ new_status = "failed"
1023
+ fs["continuation_pending"] = False
1024
+ fs["needs_attention"] = True
1025
+ if session_id:
1026
+ fs["last_failed_session_id"] = session_id
1027
+ else:
1028
+ new_status = "in_progress"
1029
+ if current_list_status != new_status:
1030
+ err = update_feature_in_list(feature_list_path, feature_id, new_status)
1022
1031
  if err:
1023
1032
  error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
1024
1033
  return
@@ -1131,7 +1140,7 @@ def action_update(args, feature_list_path, state_dir):
1131
1140
  summary.update(continuation_metadata_summary(fs))
1132
1141
  summary["artifacts_preserved"] = True
1133
1142
  elif session_status == "context_overflow":
1134
- summary["restart_policy"] = "context_overflow_continuation"
1143
+ summary["restart_policy"] = "context_overflow_continuation" if new_status == "in_progress" else "needs_attention"
1135
1144
  summary.update(continuation_metadata_summary(fs))
1136
1145
  summary["artifacts_preserved"] = True
1137
1146
  elif session_status == "workflow_skipped":
@@ -590,11 +590,20 @@ def action_update(args, refactor_list_path, state_dir):
590
590
  error_out("Failed to update .prizmkit/plans/refactor-list.json: {}".format(err))
591
591
  return
592
592
  elif session_status == "context_overflow":
593
- # Context overflow is continuation state, not code or infra retry state.
593
+ # Preserve resumable context metadata, but every relaunched AI session
594
+ # must consume the existing bounded retry budget.
594
595
  set_context_overflow_continuation_metadata(rs, args, session_id)
595
- new_status = "in_progress"
596
- if current_list_status != "in_progress":
597
- err = update_refactor_in_list(refactor_list_path, refactor_id, "in_progress")
596
+ rs["retry_count"] = rs.get("retry_count", 0) + 1
597
+ if rs["retry_count"] >= max_retries:
598
+ new_status = "failed"
599
+ rs["continuation_pending"] = False
600
+ rs["needs_attention"] = True
601
+ if session_id:
602
+ rs["last_failed_session_id"] = session_id
603
+ else:
604
+ new_status = "in_progress"
605
+ if current_list_status != new_status:
606
+ err = update_refactor_in_list(refactor_list_path, refactor_id, new_status)
598
607
  if err:
599
608
  error_out("Failed to update .prizmkit/plans/refactor-list.json: {}".format(err))
600
609
  return
@@ -702,7 +711,7 @@ def action_update(args, refactor_list_path, state_dir):
702
711
  summary.update(continuation_metadata_summary(rs))
703
712
  summary["artifacts_preserved"] = True
704
713
  elif session_status == "context_overflow":
705
- summary["restart_policy"] = "context_overflow_continuation"
714
+ summary["restart_policy"] = "context_overflow_continuation" if new_status == "in_progress" else "needs_attention"
706
715
  summary.update(continuation_metadata_summary(rs))
707
716
  summary["artifacts_preserved"] = True
708
717
  elif session_status == "workflow_skipped":
@@ -221,7 +221,7 @@ class TestContextOverflowRetrySemantics:
221
221
  ]:
222
222
  assert key not in status
223
223
 
224
- def test_context_overflow_stays_in_progress_and_records_continuation_metadata(self, tmp_path):
224
+ def test_context_overflow_consumes_retry_and_records_bounded_continuation_metadata(self, tmp_path):
225
225
  scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
226
226
 
227
227
  for case in self._script_cases(scripts_dir):
@@ -234,7 +234,7 @@ class TestContextOverflowRetrySemantics:
234
234
  write_json_file(str(list_path), case["list_payload"])
235
235
  status_path = state_dir / case["id"] / "status.json"
236
236
  write_json_file(str(status_path), {
237
- "retry_count": 2,
237
+ "retry_count": 1,
238
238
  "infra_error_count": 1,
239
239
  "sessions": [],
240
240
  "last_session_id": None,
@@ -316,6 +316,64 @@ class TestContextOverflowRetrySemantics:
316
316
  assert selected["continuation_count"] == 1
317
317
  assert selected["continuation_summary_path"].endswith("continuation-summary.md")
318
318
 
319
+ def test_context_overflow_reaching_retry_limit_stops_all_families(self, tmp_path):
320
+ scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
321
+
322
+ for case in self._script_cases(scripts_dir):
323
+ case_dir = tmp_path / f"bounded-{case['id']}"
324
+ state_dir = case_dir / "state"
325
+ list_path = case_dir / case["list_name"]
326
+ session_dir = state_dir / case["id"] / "sessions" / "sess-limit"
327
+ session_dir.mkdir(parents=True)
328
+ (session_dir / "session.log").write_text("context overflow diagnostics", encoding="utf-8")
329
+ write_json_file(str(list_path), case["list_payload"])
330
+ status_path = state_dir / case["id"] / "status.json"
331
+ write_json_file(str(status_path), {
332
+ "retry_count": 2,
333
+ "infra_error_count": 1,
334
+ "sessions": [],
335
+ "last_session_id": None,
336
+ "resume_from_phase": None,
337
+ })
338
+
339
+ summary = self._run_case_update(
340
+ case,
341
+ list_path,
342
+ state_dir,
343
+ "context_overflow",
344
+ session_id="sess-limit",
345
+ extra_args=[
346
+ "--active-dev-branch",
347
+ "dev/existing-branch",
348
+ "--base-branch",
349
+ "main",
350
+ "--last-fatal-error-code",
351
+ "context_overflow",
352
+ ],
353
+ )
354
+
355
+ assert summary["new_status"] == "failed"
356
+ assert summary["retry_count"] == 3
357
+ assert summary["infra_error_count"] == 1
358
+ assert summary["context_overflow_count"] == 1
359
+ assert summary["continuation_count"] == 1
360
+ assert summary["continuation_pending"] is False
361
+ assert summary["restart_policy"] == "needs_attention"
362
+ assert summary["artifacts_preserved"] is True
363
+ assert (session_dir / "session.log").exists()
364
+
365
+ list_data, _ = load_json_file(str(list_path))
366
+ assert list_data[case["collection"]][0]["status"] == "failed"
367
+
368
+ runtime = self._runtime_status(state_dir, case["id"])
369
+ assert runtime["retry_count"] == 3
370
+ assert runtime["continuation_pending"] is False
371
+ assert runtime["continuation_reason"] == "context_overflow"
372
+ assert runtime["needs_attention"] is True
373
+ assert runtime["last_failed_session_id"] == "sess-limit"
374
+ assert runtime["context_overflow_count"] == 1
375
+ assert runtime["continuation_count"] == 1
376
+
319
377
  def test_context_overflow_from_non_started_item_marks_in_progress_once(self, tmp_path):
320
378
  scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
321
379
 
@@ -768,7 +826,7 @@ def _build_status_updater_behavior_snapshot(tmp_path):
768
826
  case_dir, list_path, state_dir = _prepare_snapshot_case(
769
827
  tmp_path / "context-overflow",
770
828
  case,
771
- runtime_status={"retry_count": 2, "infra_error_count": 1},
829
+ runtime_status={"retry_count": 1, "infra_error_count": 1},
772
830
  )
773
831
  summary_path = case_dir / "continuation-summary.md"
774
832
  summary = _run_status_snapshot_action(
@@ -1484,7 +1542,7 @@ def test_context_overflow_records_no_progress_fingerprint_and_stall(tmp_path):
1484
1542
  ], text=True, capture_output=True, check=True)
1485
1543
  first_summary = json.loads(first.stdout)
1486
1544
  assert first_summary['new_status'] == 'in_progress'
1487
- assert first_summary['retry_count'] == 0
1545
+ assert first_summary['retry_count'] == 1
1488
1546
  assert first_summary['infra_error_count'] == 0
1489
1547
  assert first_summary['no_progress_count'] == 1
1490
1548
 
@@ -1504,7 +1562,7 @@ def test_context_overflow_records_no_progress_fingerprint_and_stall(tmp_path):
1504
1562
  second_summary = json.loads(second.stdout)
1505
1563
  status = load_feature_status(str(state_dir), 'F-001')
1506
1564
  assert second_summary['new_status'] == 'failed'
1507
- assert second_summary['retry_count'] == 0
1565
+ assert second_summary['retry_count'] == 1
1508
1566
  assert second_summary['infra_error_count'] == 0
1509
1567
  assert second_summary['needs_attention'] is True
1510
1568
  assert second_summary['stalled_context_continuation'] is True
@@ -1079,7 +1079,7 @@ def test_prompt_generation_passes_explicit_execution_root_to_generator(monkeypat
1079
1079
  assert result.artifact_path == execution_root / ".prizmkit" / "specs" / "001-low"
1080
1080
 
1081
1081
 
1082
- def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",), update_status="completed"):
1082
+ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",), update_status="completed", max_retries=3):
1083
1083
  from prizmkit_runtime.runner_models import PromptGenerationResult, SessionClassification
1084
1084
 
1085
1085
  prompt_calls = []
@@ -1148,16 +1148,26 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
1148
1148
 
1149
1149
  def fake_update(family, invocation, item_id, session_status, session_id, project_root, **metadata):
1150
1150
  updates.append((session_status, session_id, metadata))
1151
+ normal_retry_count = sum(
1152
+ status in {"context_overflow", "crashed", "timed_out", "failed"}
1153
+ for status, *_ in updates
1154
+ )
1155
+ infra_error_count = sum(status == "infra_error" for status, *_ in updates)
1151
1156
  current_update_status = update_status
1152
- if session_status in {"context_overflow", "infra_error", "crashed", "timed_out", "failed"} and len(status_queue) > 0:
1157
+ if session_status in {"context_overflow", "crashed", "timed_out", "failed"}:
1158
+ if normal_retry_count >= max_retries:
1159
+ current_update_status = "failed"
1160
+ elif len(status_queue) > 0:
1161
+ current_update_status = "in_progress"
1162
+ elif session_status == "infra_error" and len(status_queue) > 0:
1153
1163
  current_update_status = "in_progress"
1154
1164
  return SimpleNamespace(
1155
1165
  ok=True,
1156
1166
  text="",
1157
1167
  data={
1158
1168
  "new_status": current_update_status,
1159
- "retry_count": sum(status in {"crashed", "timed_out", "failed"} for status, *_ in updates),
1160
- "infra_error_count": sum(status == "infra_error" for status, *_ in updates),
1169
+ "retry_count": normal_retry_count,
1170
+ "infra_error_count": infra_error_count,
1161
1171
  "continuation_count": len(updates),
1162
1172
  "context_overflow_count": sum(status == "context_overflow" for status, *_ in updates),
1163
1173
  },
@@ -2002,8 +2012,14 @@ def test_no_worktree_interrupt_saves_wip_and_returns_to_base(monkeypatch, tmp_pa
2002
2012
  stdout=subprocess.PIPE,
2003
2013
  text=True,
2004
2014
  ).stdout == "preserved\n"
2015
+ assert "Interrupt diagnostics: trigger=keyboard_interrupt; platform_signal=SIGINT(2) (inferred)" in captured.err
2016
+ assert "Interrupt context: task=F-001; task_type=feature; session=F-001-session-1" in captured.err
2017
+ assert "Retry accounting unchanged: code=0/3; infrastructure=0/3" in captured.err
2018
+ assert "No session classification or task-status update will be written" in captured.err
2005
2019
  assert "Interrupted task F-001: WIP committed on dev/F-001-interrupt" in captured.err
2006
2020
  assert "caller checkout returned to main; no merge or push performed" in captured.err
2021
+ assert observed.updates == []
2022
+ assert not observed.prompt_calls[0]["session_paths"].session_status_json.exists()
2007
2023
  assert observed.merge_calls == []
2008
2024
  assert observed.cleanup_calls == []
2009
2025
 
@@ -2117,6 +2133,7 @@ def test_worktree_interrupt_uses_same_wip_and_base_return_contract(monkeypatch,
2117
2133
 
2118
2134
  def test_run_pipeline_command_bridges_sigterm_and_restores_handler(monkeypatch, tmp_path):
2119
2135
  from prizmkit_runtime import runners
2136
+ from prizmkit_runtime.processes import describe_interruption
2120
2137
  from prizmkit_runtime.runner_models import PipelineRunResult
2121
2138
 
2122
2139
  paths = _make_paths(tmp_path)
@@ -2126,10 +2143,10 @@ def test_run_pipeline_command_bridges_sigterm_and_restores_handler(monkeypatch,
2126
2143
 
2127
2144
  def interrupted_pipeline(*args, **kwargs):
2128
2145
  installed = signal.getsignal(signal.SIGTERM)
2129
- observed.append(installed)
2130
2146
  try:
2131
2147
  installed(signal.SIGTERM, None)
2132
- except KeyboardInterrupt:
2148
+ except KeyboardInterrupt as interruption:
2149
+ observed.append((installed, describe_interruption(interruption)))
2133
2150
  return PipelineRunResult(False, 1, "interrupted", "interrupted")
2134
2151
  raise AssertionError("SIGTERM handler did not interrupt the pipeline")
2135
2152
 
@@ -2139,7 +2156,10 @@ def test_run_pipeline_command_bridges_sigterm_and_restores_handler(monkeypatch,
2139
2156
 
2140
2157
  assert result.exit_code == 1
2141
2158
  assert result.message == "processed=1 completed=False stopped_reason=interrupted"
2142
- assert observed and observed[0] not in {previous, signal.SIG_DFL, signal.SIG_IGN}
2159
+ assert observed and observed[0][0] not in {previous, signal.SIG_DFL, signal.SIG_IGN}
2160
+ assert observed[0][1].trigger == "platform_signal"
2161
+ assert observed[0][1].signal_label == "SIGTERM(15)"
2162
+ assert observed[0][1].signal_exact is True
2143
2163
  assert signal.getsignal(signal.SIGTERM) is previous
2144
2164
 
2145
2165
 
@@ -3795,6 +3815,72 @@ def test_terminal_success_without_semantic_completion_is_skipped(tmp_path):
3795
3815
  assert classification.fatal_error_code == ""
3796
3816
 
3797
3817
 
3818
+ def test_pi_length_stop_with_nonzero_exit_uses_bounded_crash_path(tmp_path):
3819
+ from dataclasses import replace
3820
+
3821
+ from prizmkit_runtime.runner_classification import classify_session
3822
+ from prizmkit_runtime.status import ProgressSummary
3823
+
3824
+ base_head = _init_git_repo_for_invalid_checkpoint_test(tmp_path)
3825
+ raw = replace(
3826
+ _terminal_success_result(tmp_path),
3827
+ exit_code=143,
3828
+ progress_summary=ProgressSummary(result_subtype="success", stop_reason="length"),
3829
+ )
3830
+
3831
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
3832
+
3833
+ assert classification.session_status == "crashed"
3834
+ assert classification.reason == "exit_code=143"
3835
+ assert classification.fatal_error_code == ""
3836
+
3837
+
3838
+ def test_pi_length_stop_with_zero_exit_and_no_checkpoint_uses_no_completion_policy(tmp_path):
3839
+ from dataclasses import replace
3840
+
3841
+ from prizmkit_runtime.runner_classification import classify_session
3842
+ from prizmkit_runtime.status import ProgressSummary
3843
+
3844
+ base_head = _init_git_repo_for_invalid_checkpoint_test(tmp_path)
3845
+ raw = replace(
3846
+ _terminal_success_result(tmp_path),
3847
+ progress_summary=ProgressSummary(result_subtype="success", stop_reason="length"),
3848
+ )
3849
+
3850
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
3851
+
3852
+ assert classification.session_status == "workflow_skipped"
3853
+ assert classification.reason == "no_semantic_completion"
3854
+ assert classification.fatal_error_code == ""
3855
+
3856
+
3857
+ def test_pi_length_stop_does_not_override_completed_checkpoint(tmp_path):
3858
+ from dataclasses import replace
3859
+
3860
+ from prizmkit_runtime.runner_classification import classify_session
3861
+ from prizmkit_runtime.runner_models import PromptGenerationResult
3862
+ from prizmkit_runtime.status import ProgressSummary
3863
+
3864
+ base_head = _init_git_repo_for_invalid_checkpoint_test(tmp_path)
3865
+ artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-length-complete"
3866
+ checkpoint = _write_semantic_completion_checkpoint(tmp_path, artifact_dir)
3867
+ prompt = PromptGenerationResult(
3868
+ output_path=tmp_path / "prompt.md",
3869
+ checkpoint_path=checkpoint,
3870
+ artifact_path=artifact_dir,
3871
+ )
3872
+ raw = replace(
3873
+ _terminal_success_result(tmp_path),
3874
+ progress_summary=ProgressSummary(result_subtype="success", stop_reason="length"),
3875
+ )
3876
+
3877
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head, prompt=prompt)
3878
+
3879
+ assert classification.session_status == "success"
3880
+ assert classification.reason == "terminal_success_with_semantic_checkpoint"
3881
+ assert classification.fatal_error_code == ""
3882
+
3883
+
3798
3884
  def test_terminal_success_text_does_not_hide_provider_failure(tmp_path):
3799
3885
  from prizmkit_runtime.runner_classification import classify_session
3800
3886
  from prizmkit_runtime.runner_models import PromptGenerationResult
@@ -4024,6 +4110,46 @@ def test_retryable_ai_errors_continue_in_process_for_all_families(monkeypatch, t
4024
4110
  assert continuation["active_dev_branch"] == branch
4025
4111
 
4026
4112
 
4113
+ @pytest.mark.parametrize(
4114
+ ("kind", "item_id", "branch"),
4115
+ [
4116
+ ("feature", "F-001", "dev/F-001-context-budget"),
4117
+ ("bugfix", "B-001", "bugfix/B-001-context-budget"),
4118
+ ("refactor", "R-001", "refactor/R-001-context-budget"),
4119
+ ],
4120
+ )
4121
+ def test_context_overflow_retry_budget_prevents_session_n_plus_one(monkeypatch, tmp_path, kind, item_id, branch):
4122
+ from prizmkit_runtime import runners
4123
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
4124
+
4125
+ monkeypatch.setenv("DEV_BRANCH", branch)
4126
+ paths = _make_paths(tmp_path / kind)
4127
+ family = family_for(kind, paths)
4128
+ invocation = parse_invocation(family, "run", ())
4129
+ plan_path = {"feature": paths.feature_plan, "bugfix": paths.bugfix_plan, "refactor": paths.refactor_plan}[kind]
4130
+ invocation = invocation.__class__(**{**invocation.__dict__, "list_path": plan_path})
4131
+ observed = _install_runner_session_fakes(
4132
+ monkeypatch,
4133
+ runners,
4134
+ statuses=("context_overflow", "context_overflow", "context_overflow", "context_overflow"),
4135
+ max_retries=3,
4136
+ )
4137
+ sleep_calls = []
4138
+ monkeypatch.setattr(runners.time, "sleep", lambda seconds: sleep_calls.append(seconds))
4139
+ monkeypatch.setattr(
4140
+ runners,
4141
+ "run_git_plan",
4142
+ lambda project_root, plan: SimpleNamespace(ok=True, plan=plan, results=()),
4143
+ )
4144
+
4145
+ status = runners._process_item(family, invocation, item_id, paths, initial_metadata={})
4146
+
4147
+ assert status == "failed"
4148
+ assert len(observed.launch_cwds) == 3
4149
+ assert len(observed.updates) == 3
4150
+ assert sleep_calls == [5, 5]
4151
+
4152
+
4027
4153
  def test_unique_session_ids_do_not_collide_when_wall_clock_second_is_frozen(monkeypatch):
4028
4154
  from prizmkit_runtime import runners
4029
4155
 
@@ -994,6 +994,29 @@ def test_progress_parser_does_not_mark_failed_pi_agent_end_as_success():
994
994
  assert "provider request failed" in data["errors"]
995
995
 
996
996
 
997
+ def test_progress_parser_keeps_pi_length_as_nonfatal_diagnostic_metadata():
998
+ module = _load_progress_parser_module()
999
+ tracker = module.ProgressTracker()
1000
+ tracker.process_event({"type": "session", "version": 3, "id": "pi-session", "cwd": "/tmp/project"})
1001
+ tracker.process_event({"type": "agent_start"})
1002
+ message = {
1003
+ "role": "assistant",
1004
+ "content": [{"type": "thinking", "thinking": "Planning the next test"}],
1005
+ "stopReason": "length",
1006
+ }
1007
+ tracker.process_event({"type": "message_end", "message": message})
1008
+ tracker.process_event({"type": "agent_end", "willRetry": False, "messages": [message]})
1009
+
1010
+ data = tracker.to_dict()
1011
+
1012
+ assert data["result_subtype"] == "success"
1013
+ assert data["stop_reason"] == "length"
1014
+ assert data["last_result_is_error"] is False
1015
+ assert data["api_error_code"] == ""
1016
+ assert data["fatal_error_code"] == ""
1017
+ assert data["terminal_success_at"]
1018
+
1019
+
997
1020
  def test_progress_parser_recovers_from_retrying_pi_attempt_before_final_success():
998
1021
  module = _load_progress_parser_module()
999
1022
  tracker = module.ProgressTracker()
@@ -1190,6 +1213,21 @@ def test_semantic_log_normalizer_emits_terminal_metadata_once():
1190
1213
  assert "[FINAL] Done" in rendered
1191
1214
 
1192
1215
 
1216
+ def test_semantic_log_normalizer_does_not_promote_pi_length_to_context_overflow():
1217
+ module = _load_progress_parser_module()
1218
+ normalizer = module.SemanticLogNormalizer()
1219
+ rendered = normalizer.process_event({
1220
+ "type": "agent_end",
1221
+ "willRetry": False,
1222
+ "messages": [{"role": "assistant", "content": [], "stopReason": "length"}],
1223
+ })
1224
+ rendered += normalizer.finish()
1225
+
1226
+ assert rendered.count("[SESSION_END]") == 1
1227
+ assert "status=success stop_reason=length" in rendered
1228
+ assert "status=context_overflow" not in rendered
1229
+
1230
+
1193
1231
  def test_semantic_log_normalizer_preserves_identical_text_from_distinct_turns():
1194
1232
  module = _load_progress_parser_module()
1195
1233
  normalizer = module.SemanticLogNormalizer()
@@ -2214,6 +2252,159 @@ def test_session_summary_reports_normalized_subagents_and_child_liveness(tmp_pat
2214
2252
  assert "Child log activity: 2KB across 1 files (heartbeat liveness only)" in captured.err
2215
2253
  assert "Subagent calls detected" not in captured.err
2216
2254
 
2255
+ def test_ai_interruption_diagnostics_are_detailed_console_only(tmp_path, capsys):
2256
+ import signal
2257
+
2258
+ from prizmkit_runtime.heartbeat import HeartbeatState
2259
+ from prizmkit_runtime.processes import (
2260
+ ProcessHandle,
2261
+ ProcessSpec,
2262
+ ProcessTerminationResult,
2263
+ RuntimeInterruption,
2264
+ )
2265
+ from prizmkit_runtime.sessions import (
2266
+ AISessionCommand,
2267
+ _emit_ai_interruption_received,
2268
+ _emit_ai_interruption_termination,
2269
+ )
2270
+
2271
+ session_log = tmp_path / "sessions" / "sess-interrupt" / "logs" / "session.log"
2272
+ session_log.parent.mkdir(parents=True)
2273
+ session_log.write_text("session activity\n", encoding="utf-8")
2274
+ progress = session_log.with_name("progress.json")
2275
+ progress.write_text(
2276
+ json.dumps(
2277
+ {
2278
+ "current_phase": "implement",
2279
+ "current_tool": "bash",
2280
+ "current_tool_input_summary": "secret-command-must-not-be-logged",
2281
+ "message_count": 455,
2282
+ "total_tool_calls": 453,
2283
+ "active_subagent_count": 0,
2284
+ }
2285
+ ),
2286
+ encoding="utf-8",
2287
+ )
2288
+ handle = ProcessHandle(
2289
+ pid=999_999,
2290
+ spec=ProcessSpec(command=("fake-ai",), cwd=tmp_path),
2291
+ status="running",
2292
+ popen=None,
2293
+ )
2294
+ command = AISessionCommand(
2295
+ argv=("fake-ai",),
2296
+ stdin_text=None,
2297
+ provider="pi",
2298
+ prompt_mode="stdin",
2299
+ )
2300
+ heartbeat = HeartbeatState(
2301
+ last_seen_epoch=time.time() - 1,
2302
+ stale=False,
2303
+ details="no progress",
2304
+ stale_seconds=1.0,
2305
+ log_size=2048,
2306
+ )
2307
+
2308
+ _emit_ai_interruption_received(
2309
+ RuntimeInterruption(signal.SIGTERM),
2310
+ command=command,
2311
+ handle=handle,
2312
+ started_epoch=time.time() - 120,
2313
+ session_log=session_log,
2314
+ progress_path=progress,
2315
+ checkpoint_path=None,
2316
+ heartbeat_state=heartbeat,
2317
+ )
2318
+ _emit_ai_interruption_termination(
2319
+ ProcessTerminationResult(
2320
+ pid=handle.pid,
2321
+ reason="interrupted",
2322
+ terminated=True,
2323
+ forced=False,
2324
+ return_code=-15,
2325
+ details=("sigterm_process_group",),
2326
+ )
2327
+ )
2328
+
2329
+ captured = capsys.readouterr()
2330
+ assert "received trigger=platform_signal; platform_signal=SIGTERM(15)" in captured.err
2331
+ assert "runner_pid=" in captured.err
2332
+ assert "parent_pid=" in captured.err
2333
+ assert "ai_pid=999999" in captured.err
2334
+ assert "provider=pi" in captured.err
2335
+ assert "phase=implement; tool=bash; messages=455; tool_calls=453" in captured.err
2336
+ assert "heartbeat details=no progress; stale=False; age=1s" in captured.err
2337
+ assert "process-tree termination completed" in captured.err
2338
+ assert "forced=False; return_code=-15; details=sigterm_process_group" in captured.err
2339
+ assert "secret-command-must-not-be-logged" not in captured.err
2340
+ assert not session_log.with_name("session-status.json").exists()
2341
+
2342
+
2343
+ def test_ai_session_interrupt_diagnostics_are_wired_before_existing_cleanup(monkeypatch, tmp_path, capsys):
2344
+ import io
2345
+
2346
+ from prizmkit_runtime import sessions
2347
+ from prizmkit_runtime.processes import ProcessHandle, ProcessTerminationResult
2348
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
2349
+
2350
+ prompt = tmp_path / "prompt.md"
2351
+ prompt.write_text("hello", encoding="utf-8")
2352
+ session_log = tmp_path / "sessions" / "sess-wired" / "logs" / "session.log"
2353
+ termination_calls = []
2354
+
2355
+ class InterruptingProcess:
2356
+ def __init__(self):
2357
+ self.stdout = io.StringIO("")
2358
+ self.stdin = io.StringIO()
2359
+ self.returncode = -2
2360
+
2361
+ def wait(self, timeout=None):
2362
+ raise KeyboardInterrupt
2363
+
2364
+ def poll(self):
2365
+ return self.returncode
2366
+
2367
+ process = InterruptingProcess()
2368
+
2369
+ def fake_launch(spec, **kwargs):
2370
+ return ProcessHandle(pid=777_777, spec=spec, status="running", popen=process)
2371
+
2372
+ def fake_terminate(handle, reason, grace_seconds=10):
2373
+ termination_calls.append((handle.pid, reason, grace_seconds))
2374
+ return ProcessTerminationResult(
2375
+ pid=handle.pid,
2376
+ reason=reason,
2377
+ terminated=True,
2378
+ forced=False,
2379
+ return_code=process.returncode,
2380
+ details=("sigterm_process_group",),
2381
+ )
2382
+
2383
+ monkeypatch.setattr(sessions, "launch_process", fake_launch)
2384
+ monkeypatch.setattr(sessions, "terminate_process_tree", fake_terminate)
2385
+
2386
+ with pytest.raises(KeyboardInterrupt):
2387
+ AISessionLauncher(
2388
+ AISessionConfig(
2389
+ cli_command="fake-ai",
2390
+ platform="pi",
2391
+ model=None,
2392
+ prompt_path=prompt,
2393
+ cwd=tmp_path,
2394
+ log_path=session_log,
2395
+ stale_kill_grace_seconds=4,
2396
+ )
2397
+ ).run()
2398
+
2399
+ captured = capsys.readouterr()
2400
+ assert termination_calls == [(777_777, "interrupted", 4)]
2401
+ assert "received trigger=keyboard_interrupt; platform_signal=SIGINT(2) (inferred)" in captured.err
2402
+ assert "ai_pid=777777" in captured.err
2403
+ assert "classification and retry accounting have not run for this AI session" in captured.err
2404
+ assert "process-tree termination completed pid=777777" in captured.err
2405
+ assert not session_log.with_name("session-status.json").exists()
2406
+
2407
+
2217
2408
  def test_session_live_output_does_not_forward_cli_output_without_verbose(tmp_path, capsys):
2218
2409
  from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
2219
2410
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.150",
2
+ "version": "1.1.152",
3
3
  "skills": {
4
4
  "prizmkit": {
5
5
  "description": "Framework introduction and navigation for the formal single-requirement lifecycle, project initialization, Prizm docs, and independent deployment.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.150",
3
+ "version": "1.1.152",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {