prizmkit 1.1.149 → 1.1.151

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.
@@ -13,11 +13,11 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
 
15
15
  from .gitops import (
16
- HIDDEN_TOOL_WORKTREE_EXCLUDES,
17
16
  UNTRACKED_TRANSIENT_EXCLUDES,
18
17
  GitCommandResult,
19
- git_status_safe,
18
+ addable_task_paths,
20
19
  run_git_command,
20
+ visible_task_paths,
21
21
  )
22
22
  from .runner_models import RunnerFamily
23
23
 
@@ -203,7 +203,7 @@ def _remaining_preflight_changes(project_root: Path) -> GitCommandResult:
203
203
 
204
204
 
205
205
  def _visible_status_result(project_root: Path) -> GitCommandResult:
206
- return run_git_command(project_root, ("status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
206
+ return run_git_command(project_root, ("status", "--porcelain", "--", "."))
207
207
 
208
208
 
209
209
  def _paths_from_status(stdout: str) -> tuple[str, ...]:
@@ -294,12 +294,21 @@ def commit_task_branch_changes(
294
294
  message = f"chore({item_id}): save {family.kind} {status} state"
295
295
  if status == "completed":
296
296
  message = f"chore({item_id}): complete {family.kind} task"
297
- if not git_status_safe(project_root).strip():
297
+ paths, status_result = visible_task_paths(project_root)
298
+ if status_result.return_code != 0:
299
+ return BookkeepingResult(attempted=True, committed=False, result=status_result)
300
+ if not paths:
298
301
  return BookkeepingResult(attempted=False, committed=False)
299
- run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
302
+ add_paths, indexed_result = addable_task_paths(project_root, paths)
303
+ if indexed_result.return_code != 0:
304
+ return BookkeepingResult(attempted=True, committed=False, result=indexed_result)
305
+ if add_paths:
306
+ add_result = run_git_command(project_root, ("add", "-A", "--", *_literal_pathspecs(add_paths)))
307
+ if add_result.return_code != 0:
308
+ return BookkeepingResult(attempted=True, committed=False, result=add_result)
300
309
  result = run_git_command(
301
310
  project_root,
302
- ("commit", "--no-verify", "-m", message, "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES),
311
+ ("commit", "--no-verify", "-m", message, "--", *_literal_pathspecs(paths)),
303
312
  )
304
313
  return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
305
314
 
@@ -310,10 +319,19 @@ def commit_wip_changes(
310
319
  item_id: str,
311
320
  status: str,
312
321
  ) -> BookkeepingResult:
313
- """Commit all visible task-branch changes as preserved WIP."""
314
- if not git_status_safe(project_root).strip():
322
+ """Commit all Git-visible task-branch changes as preserved WIP."""
323
+ paths, status_result = visible_task_paths(project_root)
324
+ if status_result.return_code != 0:
325
+ return BookkeepingResult(attempted=True, committed=False, result=status_result)
326
+ if not paths:
315
327
  return BookkeepingResult(attempted=False, committed=False)
316
- run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
328
+ add_paths, indexed_result = addable_task_paths(project_root, paths)
329
+ if indexed_result.return_code != 0:
330
+ return BookkeepingResult(attempted=True, committed=False, result=indexed_result)
331
+ if add_paths:
332
+ add_result = run_git_command(project_root, ("add", "-A", "--", *_literal_pathspecs(add_paths)))
333
+ if add_result.return_code != 0:
334
+ return BookkeepingResult(attempted=True, committed=False, result=add_result)
317
335
  result = run_git_command(
318
336
  project_root,
319
337
  (
@@ -324,8 +342,7 @@ def commit_wip_changes(
324
342
  "-m",
325
343
  "Pipeline preserved task branch work without merging it to the base branch.",
326
344
  "--",
327
- ".",
328
- *HIDDEN_TOOL_WORKTREE_EXCLUDES,
345
+ *_literal_pathspecs(paths),
329
346
  ),
330
347
  )
331
348
  return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
@@ -168,6 +168,7 @@ def _is_terminal_success(result: AISessionResult) -> bool:
168
168
  and result.termination_reason not in {"timeout", "stale_session"}
169
169
  and not result.fatal_error_code
170
170
  and not result.progress_summary.fatal_error_code
171
+ and result.progress_summary.stop_reason.strip().lower() != "length"
171
172
  and result.progress_summary.result_subtype == "success"
172
173
  )
173
174
 
@@ -228,6 +229,8 @@ def _next_no_progress_count(previous: object | None, current: object, count: int
228
229
 
229
230
 
230
231
  def _is_context_overflow(result: AISessionResult) -> bool:
232
+ if result.progress_summary.stop_reason.strip().lower() == "length":
233
+ return True
231
234
  if (result.fatal_error_code or "").lower() == CONTEXT_OVERFLOW:
232
235
  return True
233
236
  text = "\n".join(
@@ -19,6 +19,7 @@ from .gitops import (
19
19
  branch_create_plan,
20
20
  branch_ensure_return,
21
21
  branch_merge_plan,
22
+ branch_save_wip,
22
23
  current_branch,
23
24
  default_branch,
24
25
  ensure_linked_worktree,
@@ -796,10 +797,9 @@ def _process_item_locked(
796
797
  _cleanup_interrupted_task(
797
798
  item_id=item_id,
798
799
  paths=paths,
800
+ execution_root=execution_root,
799
801
  base_branch=base_branch,
800
802
  branch_name=branch_name,
801
- use_worktree=use_worktree,
802
- worktree_runtime=worktree_runtime,
803
803
  )
804
804
  raise
805
805
 
@@ -808,41 +808,53 @@ def _cleanup_interrupted_task(
808
808
  *,
809
809
  item_id: str,
810
810
  paths,
811
+ execution_root: Path,
811
812
  base_branch: str,
812
813
  branch_name: str,
813
- use_worktree: bool,
814
- worktree_runtime,
815
814
  ) -> None:
816
- """Preserve interrupted task work and report the verified checkout state."""
817
- _emit_warning(f"Interrupted while processing {item_id}; preserving work safely")
818
- if use_worktree and worktree_runtime is not None:
819
- preserved = worktree_save_wip(worktree_runtime)
820
- if getattr(preserved, "ok", True):
821
- _emit_warning(
822
- f"Interrupted task {item_id}: work preserved in {worktree_runtime.worktree_path}; "
823
- "task worktree remains active"
824
- )
825
- return
815
+ """Commit interrupted task WIP, then leave the caller checkout on its base branch."""
816
+ _emit_warning(f"Interrupted while processing {item_id}; preserving task WIP before base return")
817
+ execution_branch = current_branch(execution_root)
818
+ if execution_root.resolve() == paths.project_root.resolve() and execution_branch == base_branch:
819
+ preserved = None
820
+ else:
821
+ preserved = branch_save_wip(execution_root, branch_name)
822
+ if not getattr(preserved, "ok", True):
826
823
  _emit_interruption_cleanup_failure(
827
824
  preserved,
828
- f"Could not preserve interrupted work for {item_id}; leaving task worktree active",
829
- f"Inspect the task worktree: {worktree_runtime.worktree_path}",
825
+ f"Could not commit interrupted WIP for {item_id}; no base return was attempted",
826
+ f"Task checkout remains active on {branch_name}; resolve the reported Git failure, then resume the recorded task",
830
827
  )
831
828
  return
832
829
 
833
830
  returned = branch_ensure_return(paths.project_root, base_branch, branch_name)
834
- if getattr(returned, "ok", True) and current_branch(paths.project_root) == base_branch:
835
- _emit_warning(f"Interrupted task {item_id}: work preserved and returned to {base_branch}")
831
+ if not getattr(returned, "ok", True) or current_branch(paths.project_root) != base_branch:
832
+ _emit_interruption_cleanup_failure(
833
+ returned,
834
+ f"Interrupted WIP for {item_id} is safe, but the caller checkout could not return to {base_branch}",
835
+ f"Task checkout remains recorded on {branch_name}; resolve the reported checkout failure, then resume the task",
836
+ )
836
837
  return
837
- _emit_interruption_cleanup_failure(
838
- returned,
839
- f"Could not safely return to {base_branch}; leaving {branch_name} active",
840
- f"After resolving the Git blocker, run: git checkout {base_branch}",
838
+
839
+ committed = any(
840
+ getattr(command_result, "args", ())[:1] == ("commit",)
841
+ and getattr(command_result, "return_code", 1) == 0
842
+ for command_result in tuple(getattr(preserved, "results", ()))
841
843
  )
844
+ if committed:
845
+ _emit_warning(
846
+ f"Interrupted task {item_id}: WIP committed on {branch_name}; caller checkout returned to {base_branch}; "
847
+ "no merge or push performed"
848
+ )
849
+ else:
850
+ _emit_warning(
851
+ f"Interrupted task {item_id}: no WIP commit needed; caller checkout returned to {base_branch}; "
852
+ "no merge or push performed"
853
+ )
842
854
 
843
855
 
844
856
  def _emit_interruption_cleanup_failure(result, message: str, recovery: str) -> None:
845
- """Emit a concise failed Git step and a non-destructive recovery action."""
857
+ """Emit the failed Git step, complete Git diagnostics, and a non-destructive recovery action."""
846
858
  _emit_warning(message)
847
859
  commands = tuple(getattr(getattr(result, "plan", None), "commands", ()))
848
860
  results = tuple(getattr(result, "results", ()))
@@ -853,9 +865,10 @@ def _emit_interruption_cleanup_failure(result, message: str, recovery: str) -> N
853
865
  if command is not None and getattr(command, "allow_failure", False):
854
866
  continue
855
867
  args = " ".join(map(str, getattr(command_result, "args", ())))
868
+ _emit_warning(f"git {args} failed")
856
869
  detail = str(getattr(command_result, "stderr", "") or getattr(command_result, "stdout", "")).strip()
857
- suffix = f": {detail.splitlines()[0][:500]}" if detail else ""
858
- _emit_warning(f"git {args} failed{suffix}")
870
+ for line in detail.splitlines():
871
+ _emit_warning(line)
859
872
  break
860
873
  _emit_warning(recovery)
861
874
 
@@ -946,8 +959,38 @@ def _complete_success_merge(
946
959
  task_commit = commit_task_branch_changes(execution_root, family, item_id, new_status)
947
960
  finalization_committed = getattr(final_bookkeeping, "ok", True) and getattr(task_commit, "ok", True)
948
961
  if not finalization_committed:
949
- _emit_warning(f"Task finalization commit failed for {item_id}; preserving task checkout without merge")
950
- if finalization_committed and worktree_runtime is not None:
962
+ _emit_warning(f"Task finalization commit failed for {item_id}; merge was not attempted and the task checkout remains active")
963
+ _write_session_status(
964
+ session_paths,
965
+ status="commit_missing",
966
+ session_id=session_id,
967
+ task_id=item_id,
968
+ task_type=family.task_type,
969
+ base_branch=base_branch,
970
+ active_dev_branch=branch_name,
971
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
972
+ reason="commit_missing",
973
+ fatal_error_code=classification_fatal_error_code,
974
+ )
975
+ update = update_item(
976
+ status_family,
977
+ status_invocation,
978
+ item_id,
979
+ "commit_missing",
980
+ session_id,
981
+ status_root,
982
+ active_dev_branch=branch_name,
983
+ base_branch=base_branch,
984
+ last_fatal_error_code=classification_fatal_error_code,
985
+ )
986
+ if not update.ok:
987
+ raise RuntimeError(update.text)
988
+ _emit_update_summary(family, item_id, update)
989
+ commit_missing_status = _status_from_update(update)
990
+ commit_final_bookkeeping(status_root, status_family, item_id, commit_missing_status)
991
+ _save_failure_wip(execution_root, family, item_id, commit_missing_status, worktree_runtime)
992
+ return commit_missing_status
993
+ if worktree_runtime is not None:
951
994
  _emit_info(f"Merging {branch_name} into {base_branch}...")
952
995
  merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
953
996
  _emit_git_plan_output(merge)
@@ -959,7 +1002,7 @@ def _complete_success_merge(
959
1002
  guarded_worktree_remove(worktree_runtime, delete_branch=True)
960
1003
  _emit_success(f"Merged {branch_name} into {base_branch}")
961
1004
  return new_status
962
- elif finalization_committed:
1005
+ else:
963
1006
  _emit_info(f"Merging {branch_name} into {base_branch}...")
964
1007
  merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
965
1008
  _emit_git_plan_output(merge)
@@ -262,6 +262,24 @@ def complete_task_checkout_locked(state_dir: Path | str, checkout: TaskCheckout)
262
262
  return persist_task_checkout(state_dir, checkout.completed())
263
263
 
264
264
 
265
+ def replace_active_task_checkout_locked(
266
+ state_dir: Path | str,
267
+ expected: TaskCheckout,
268
+ replacement: TaskCheckout,
269
+ ) -> Path:
270
+ """Replace one active checkout identity after guarded recovery preflight."""
271
+ if not expected.is_active or not replacement.is_active:
272
+ raise TaskCheckoutError("Checkout replacement requires active identities")
273
+ if (expected.task_type, expected.task_id) != (replacement.task_type, replacement.task_id):
274
+ raise TaskCheckoutError("Checkout replacement cannot change task identity")
275
+ if expected.base_branch != replacement.base_branch:
276
+ raise TaskCheckoutError("Checkout replacement cannot change the recorded base branch")
277
+ current = load_task_checkout(state_dir, expected.task_type, expected.task_id)
278
+ if current != expected:
279
+ raise TaskCheckoutError(f"Task {expected.task_id} checkout changed before recovery replacement")
280
+ return persist_task_checkout(state_dir, replacement)
281
+
282
+
265
283
  def mark_task_checkout_reset_locked(state_dir: Path | str, task_type: str, task_id: str) -> Path:
266
284
  """Publish an explicit reset boundary while the caller holds the task guard."""
267
285
  return persist_task_checkout(state_dir, TaskCheckout.reset(task_type, task_id))
@@ -277,6 +277,7 @@ class SemanticLogNormalizer:
277
277
 
278
278
  _SUCCESS_STOPS = {"stop", "end_turn", "completed", "success"}
279
279
  _ERROR_STOPS = {"error", "aborted", "failed", "cancelled", "canceled"}
280
+ _CONTEXT_STOPS = {"length"}
280
281
 
281
282
  def __init__(self):
282
283
  self.renderer = SemanticLogRenderer()
@@ -617,7 +618,10 @@ class SemanticLogNormalizer:
617
618
  will_retry = bool(event.get("willRetry"))
618
619
  if will_retry:
619
620
  return ""
620
- status = "error" if stop_reason in self._ERROR_STOPS else "success"
621
+ if stop_reason in self._CONTEXT_STOPS:
622
+ status = CONTEXT_OVERFLOW_CODE
623
+ else:
624
+ status = "error" if stop_reason in self._ERROR_STOPS else "success"
621
625
  details = f"status={status}"
622
626
  if stop_reason:
623
627
  details += f" stop_reason={stop_reason}"
@@ -768,6 +772,16 @@ class ProgressTracker:
768
772
  self._current_tool_id = ""
769
773
  self._pi_retrying_terminal_error = False
770
774
 
775
+ def _record_pi_length_stop(self):
776
+ """Normalize Pi's token-limit stop into the existing context continuation path."""
777
+ self.result_subtype = "error"
778
+ self.stop_reason = "length"
779
+ self.last_result_is_error = True
780
+ self.api_error_code = CONTEXT_OVERFLOW_CODE
781
+ self.fatal_error_code = CONTEXT_OVERFLOW_CODE
782
+ self.terminal_result_text = "stop_reason=length"
783
+ self.terminal_success_at = ""
784
+
771
785
  def process_event(self, event):
772
786
  """Process a single stream-json event and update state.
773
787
 
@@ -846,7 +860,9 @@ class ProgressTracker:
846
860
  stop_reason = str(message.get("stopReason") or "")
847
861
  if stop_reason:
848
862
  self.stop_reason = stop_reason
849
- if stop_reason in {"error", "aborted"}:
863
+ if stop_reason == "length":
864
+ self._record_pi_length_stop()
865
+ elif stop_reason in {"error", "aborted"}:
850
866
  error_text = str(message.get("errorMessage") or stop_reason)
851
867
  self.result_subtype = "error"
852
868
  self.last_result_is_error = True
@@ -880,6 +896,8 @@ class ProgressTracker:
880
896
  messages = event.get("messages") if isinstance(event.get("messages"), list) else []
881
897
  final_message = messages[-1] if messages and isinstance(messages[-1], dict) else {}
882
898
  final_stop_reason = str(final_message.get("stopReason") or self.stop_reason or "")
899
+ if final_stop_reason == "length":
900
+ self._record_pi_length_stop()
883
901
  will_retry = bool(event.get("willRetry"))
884
902
  if will_retry:
885
903
  self._pi_retrying_terminal_error = bool(
@@ -1105,6 +1105,7 @@ def _install_runner_session_fakes(monkeypatch, runners, *, statuses=("success",)
1105
1105
  monkeypatch.setattr(runners, "commit_preflight_ready_changes", lambda *args, **kwargs: SimpleNamespace(attempted=False, committed=False, ok=True, result=None))
1106
1106
  monkeypatch.setattr(runners, "commit_pre_branch_bookkeeping", lambda *args, **kwargs: None)
1107
1107
  monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda *args, **kwargs: None)
1108
+ monkeypatch.setattr(runners, "commit_task_branch_changes", lambda *args, **kwargs: None)
1108
1109
  monkeypatch.setattr(runners, "current_branch", lambda _root: "main")
1109
1110
  monkeypatch.setattr(runners, "_session_id", lambda item_id: f"{item_id}-session-{len(prompt_calls) + 1}")
1110
1111
  monkeypatch.setattr(
@@ -1722,7 +1723,7 @@ def test_success_final_commit_failure_preserves_checkout_without_merge(monkeypat
1722
1723
  checkout = load_task_checkout(family.state_dir, family.task_type, "F-001")
1723
1724
  assert checkout is not None and checkout.is_active
1724
1725
  session_status = paths.feature_state_dir / "F-001" / "sessions" / "F-001-session-1" / "session-status.json"
1725
- assert json.loads(session_status.read_text(encoding="utf-8"))["status"] == "merge_conflict"
1726
+ assert json.loads(session_status.read_text(encoding="utf-8"))["status"] == "commit_missing"
1726
1727
 
1727
1728
 
1728
1729
  def test_wip_commit_failure_skips_base_return(monkeypatch, tmp_path):
@@ -1970,7 +1971,7 @@ def test_no_worktree_interrupt_saves_wip_and_returns_to_base(monkeypatch, tmp_pa
1970
1971
  family = family_for("feature", paths)
1971
1972
  invocation = parse_invocation(family, "run", ())
1972
1973
  invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
1973
- _install_runner_session_fakes(monkeypatch, runners)
1974
+ observed = _install_runner_session_fakes(monkeypatch, runners)
1974
1975
  monkeypatch.setattr(runners, "current_branch", real_current_branch)
1975
1976
  monkeypatch.setattr(runners, "run_git_command", real_run_git_command)
1976
1977
 
@@ -2001,7 +2002,10 @@ def test_no_worktree_interrupt_saves_wip_and_returns_to_base(monkeypatch, tmp_pa
2001
2002
  stdout=subprocess.PIPE,
2002
2003
  text=True,
2003
2004
  ).stdout == "preserved\n"
2004
- assert "Interrupted task F-001: work preserved and returned to main" in captured.err
2005
+ assert "Interrupted task F-001: WIP committed on dev/F-001-interrupt" in captured.err
2006
+ assert "caller checkout returned to main; no merge or push performed" in captured.err
2007
+ assert observed.merge_calls == []
2008
+ assert observed.cleanup_calls == []
2005
2009
 
2006
2010
 
2007
2011
  def test_interrupt_return_failure_is_reported_without_forcing_checkout(monkeypatch, tmp_path, capsys):
@@ -2034,10 +2038,11 @@ def test_interrupt_return_failure_is_reported_without_forcing_checkout(monkeypat
2034
2038
  args=("checkout", "main"),
2035
2039
  return_code=1,
2036
2040
  stdout="",
2037
- stderr="untracked files would be overwritten",
2041
+ stderr="untracked files would be overwritten\nconflicting path: local.txt",
2038
2042
  ),
2039
2043
  ),
2040
2044
  )
2045
+ monkeypatch.setattr(runners, "branch_save_wip", lambda project_root, branch: SimpleNamespace(ok=True, results=()))
2041
2046
  monkeypatch.setattr(runners, "branch_ensure_return", lambda project_root, base, branch: failure)
2042
2047
 
2043
2048
  class InterruptingLauncher:
@@ -2053,12 +2058,14 @@ def test_interrupt_return_failure_is_reported_without_forcing_checkout(monkeypat
2053
2058
  runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
2054
2059
 
2055
2060
  captured = capsys.readouterr()
2056
- assert "Could not safely return to main; leaving dev/F-001-interrupt active" in captured.err
2057
- assert "git checkout main failed: untracked files would be overwritten" in captured.err
2058
- assert "After resolving the Git blocker, run: git checkout main" in captured.err
2061
+ assert "Interrupted WIP for F-001 is safe, but the caller checkout could not return to main" in captured.err
2062
+ assert "git checkout main failed" in captured.err
2063
+ assert "untracked files would be overwritten" in captured.err
2064
+ assert "conflicting path: local.txt" in captured.err
2065
+ assert "After resolving the Git blocker" not in captured.err
2059
2066
 
2060
2067
 
2061
- def test_worktree_interrupt_preserves_task_worktree_without_returning_caller_checkout(monkeypatch, tmp_path, capsys):
2068
+ def test_worktree_interrupt_uses_same_wip_and_base_return_contract(monkeypatch, tmp_path, capsys):
2062
2069
  from prizmkit_runtime import runners
2063
2070
  from prizmkit_runtime.runner_models import family_for, parse_invocation
2064
2071
 
@@ -2074,11 +2081,17 @@ def test_worktree_interrupt_preserves_task_worktree_without_returning_caller_che
2074
2081
  "ensure_linked_worktree",
2075
2082
  lambda runtime: SimpleNamespace(ok=True, runtime=runtime, reason=""),
2076
2083
  )
2084
+ preserve_calls = []
2077
2085
  return_calls = []
2086
+ monkeypatch.setattr(
2087
+ runners,
2088
+ "branch_save_wip",
2089
+ lambda root, branch: preserve_calls.append((root, branch)) or SimpleNamespace(ok=True, results=()),
2090
+ )
2078
2091
  monkeypatch.setattr(
2079
2092
  runners,
2080
2093
  "branch_ensure_return",
2081
- lambda *args: return_calls.append(args),
2094
+ lambda *args: return_calls.append(args) or SimpleNamespace(ok=True, results=()),
2082
2095
  )
2083
2096
 
2084
2097
  class InterruptingLauncher:
@@ -2094,10 +2107,12 @@ def test_worktree_interrupt_preserves_task_worktree_without_returning_caller_che
2094
2107
  runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
2095
2108
 
2096
2109
  captured = capsys.readouterr()
2097
- assert observed.wip_calls
2098
- assert return_calls == []
2099
- assert "Interrupted task F-001: work preserved in" in captured.err
2100
- assert "task worktree remains active" in captured.err
2110
+ assert preserve_calls and preserve_calls[0][0] != paths.project_root
2111
+ assert return_calls == [(paths.project_root, "main", "dev/F-001-interrupt")]
2112
+ assert "Interrupted task F-001: no WIP commit needed" in captured.err
2113
+ assert "caller checkout returned to main; no merge or push performed" in captured.err
2114
+ assert observed.merge_calls == []
2115
+ assert observed.cleanup_calls == []
2101
2116
 
2102
2117
 
2103
2118
  def test_run_pipeline_command_bridges_sigterm_and_restores_handler(monkeypatch, tmp_path):
@@ -2383,6 +2398,45 @@ def test_tracked_python_cache_remains_visible_to_task_commit(tmp_path):
2383
2398
  ).stdout == ""
2384
2399
 
2385
2400
 
2401
+ @pytest.mark.parametrize("helper_name", ["commit_task_branch_changes", "commit_wip_changes"])
2402
+ def test_task_commit_helpers_propagate_staging_failure_without_commit(monkeypatch, tmp_path, helper_name):
2403
+ from prizmkit_runtime import runner_bookkeeping
2404
+ from prizmkit_runtime.gitops import GitCommandResult
2405
+ from prizmkit_runtime.runner_models import family_for
2406
+
2407
+ paths = _make_paths(tmp_path)
2408
+ family = family_for("bugfix", paths)
2409
+ status_result = GitCommandResult(("status",), 0, " M app.txt\0", "")
2410
+ indexed_result = GitCommandResult(("ls-files",), 0, "app.txt\0", "")
2411
+ monkeypatch.setattr(
2412
+ runner_bookkeeping,
2413
+ "visible_task_paths",
2414
+ lambda _root: (("app.txt",), status_result),
2415
+ )
2416
+ monkeypatch.setattr(
2417
+ runner_bookkeeping,
2418
+ "addable_task_paths",
2419
+ lambda _root, _paths: (("app.txt",), indexed_result),
2420
+ )
2421
+ calls = []
2422
+
2423
+ def fail_add(_root, args):
2424
+ args = tuple(args)
2425
+ calls.append(args)
2426
+ assert args[0] != "commit"
2427
+ return GitCommandResult(args, 1, "", "staging failed")
2428
+
2429
+ monkeypatch.setattr(runner_bookkeeping, "run_git_command", fail_add)
2430
+ helper = getattr(runner_bookkeeping, helper_name)
2431
+
2432
+ result = helper(paths.project_root, family, "B-001", "failed")
2433
+
2434
+ assert result.attempted is True
2435
+ assert result.committed is False
2436
+ assert result.result is not None and result.result.stderr == "staging failed"
2437
+ assert calls and all(args[0] != "commit" for args in calls)
2438
+
2439
+
2386
2440
  def test_preflight_ready_commit_includes_tracked_state_but_excludes_untracked_state(tmp_path):
2387
2441
  from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
2388
2442
 
@@ -3741,6 +3795,25 @@ def test_terminal_success_without_semantic_completion_is_skipped(tmp_path):
3741
3795
  assert classification.fatal_error_code == ""
3742
3796
 
3743
3797
 
3798
+ def test_pi_length_stop_uses_existing_context_overflow_continuation(tmp_path):
3799
+ from dataclasses import replace
3800
+
3801
+ from prizmkit_runtime.runner_classification import classify_session
3802
+ from prizmkit_runtime.status import ProgressSummary
3803
+
3804
+ base_head = _init_git_repo_for_invalid_checkpoint_test(tmp_path)
3805
+ raw = replace(
3806
+ _terminal_success_result(tmp_path),
3807
+ progress_summary=ProgressSummary(result_subtype="success", stop_reason="length"),
3808
+ )
3809
+
3810
+ classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
3811
+
3812
+ assert classification.session_status == "context_overflow"
3813
+ assert classification.reason == "context_overflow"
3814
+ assert classification.fatal_error_code == "context_overflow"
3815
+
3816
+
3744
3817
  def test_terminal_success_text_does_not_hide_provider_failure(tmp_path):
3745
3818
  from prizmkit_runtime.runner_classification import classify_session
3746
3819
  from prizmkit_runtime.runner_models import PromptGenerationResult