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.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/processes.py +71 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +69 -4
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +125 -3
- package/bundled/dev-pipeline/scripts/update-bug-status.py +14 -5
- package/bundled/dev-pipeline/scripts/update-feature-status.py +14 -5
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +14 -5
- package/bundled/dev-pipeline/tests/test_auto_skip.py +63 -5
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +133 -7
- package/bundled/dev-pipeline/tests/test_unified_cli.py +191 -0
- package/bundled/skills/_metadata.json +1 -1
- package/package.json +1 -1
package/bundled/VERSION.json
CHANGED
|
@@ -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(
|
|
60
|
+
def _interrupt_from_sigterm(signal_number, _frame) -> None:
|
|
60
61
|
"""Translate daemon SIGTERM into the existing fail-closed interrupt path."""
|
|
61
|
-
raise
|
|
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
|
|
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(
|
|
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
|
-
#
|
|
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
|
-
|
|
592
|
-
if
|
|
593
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1020
|
-
if
|
|
1021
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
596
|
-
if
|
|
597
|
-
|
|
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
|
|
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":
|
|
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":
|
|
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'] ==
|
|
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'] ==
|
|
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", "
|
|
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":
|
|
1160
|
-
"infra_error_count":
|
|
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
|
|