ai-push-hooks 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +80 -993
  3. package/SECURITY.md +21 -16
  4. package/ai-push-hooks.toml +1 -1
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/docs/configuration.md +263 -0
  7. package/package.json +4 -3
  8. package/pyproject.toml +12 -3
  9. package/src/ai_push_hooks/artifacts.py +19 -19
  10. package/src/ai_push_hooks/cli.py +24 -8
  11. package/src/ai_push_hooks/config.py +239 -54
  12. package/src/ai_push_hooks/engine.py +94 -43
  13. package/src/ai_push_hooks/executors/apply.py +124 -46
  14. package/src/ai_push_hooks/executors/ask.py +30 -536
  15. package/src/ai_push_hooks/executors/exec.py +73 -818
  16. package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
  17. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  18. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  19. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  20. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  21. package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
  22. package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
  23. package/src/ai_push_hooks/executors/runners/process.py +97 -17
  24. package/src/ai_push_hooks/executors/runners/registry.py +31 -9
  25. package/src/ai_push_hooks/executors/step_commands.py +65 -20
  26. package/src/ai_push_hooks/git_utils.py +899 -0
  27. package/src/ai_push_hooks/hook.py +49 -13
  28. package/src/ai_push_hooks/install.py +40 -18
  29. package/src/ai_push_hooks/modules/beads.py +19 -8
  30. package/src/ai_push_hooks/modules/docs.py +146 -96
  31. package/src/ai_push_hooks/modules/pr.py +19 -8
  32. package/src/ai_push_hooks/paths.py +6 -2
  33. package/src/ai_push_hooks/plugin_loader.py +182 -105
  34. package/src/ai_push_hooks/plugins.py +3 -1
  35. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  36. package/src/ai_push_hooks/types.py +48 -27
  37. package/vendor/README.md +15 -0
  38. package/vendor/requirements.txt +1 -0
  39. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  40. package/run.sh +0 -29
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
5
6
  import os
6
7
  import pathlib
7
8
  import signal
@@ -58,6 +59,7 @@ def _read_bounded(
58
59
  output: bytearray,
59
60
  truncated: list[bool],
60
61
  output_lock: threading.Lock,
62
+ output_limit_reached: threading.Event,
61
63
  ) -> None:
62
64
  read = getattr(stream, "read")
63
65
  try:
@@ -74,6 +76,7 @@ def _read_bounded(
74
76
  output.extend(chunk[:remaining])
75
77
  if len(chunk) > remaining:
76
78
  truncated[0] = True
79
+ output_limit_reached.set()
77
80
  finally:
78
81
  _close_pipe(stream)
79
82
 
@@ -213,6 +216,7 @@ def run_process(
213
216
  timeout_seconds: float,
214
217
  env: Mapping[str, str] | None = None,
215
218
  max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
219
+ max_stderr_bytes: int | None = None,
216
220
  ) -> ProcessResult:
217
221
  """Run an argv vector directly, with bounded concurrent stream capture.
218
222
 
@@ -222,18 +226,39 @@ def run_process(
222
226
  timeout and signal termination are raised as distinct fail-closed errors.
223
227
  """
224
228
 
225
- if not isinstance(argv, (tuple, list)) or not argv or any(
226
- not isinstance(argument, str) or not argument or "\x00" in argument for argument in argv
229
+ if (
230
+ not isinstance(argv, (tuple, list))
231
+ or not argv
232
+ or any(
233
+ not isinstance(argument, str) or not argument or "\x00" in argument
234
+ for argument in argv
235
+ )
227
236
  ):
228
237
  raise RunnerError("runner command must be a non-empty NUL-free argv vector")
229
238
  if not isinstance(cwd, pathlib.Path):
230
239
  cwd = pathlib.Path(cwd)
231
240
  if not cwd.is_dir():
232
241
  raise RunnerError("runner cwd must be an existing directory")
233
- if timeout_seconds <= 0:
234
- raise RunnerError("runner timeout must be greater than zero")
235
- if max_output_bytes < 0:
242
+ if (
243
+ isinstance(timeout_seconds, bool)
244
+ or not isinstance(timeout_seconds, (int, float))
245
+ or not math.isfinite(timeout_seconds)
246
+ or timeout_seconds <= 0
247
+ ):
248
+ raise RunnerError("runner timeout must be a finite number greater than zero")
249
+ if (
250
+ isinstance(max_output_bytes, bool)
251
+ or not isinstance(max_output_bytes, int)
252
+ or max_output_bytes < 0
253
+ ):
236
254
  raise RunnerError("runner output bound must not be negative")
255
+ stderr_limit = max_output_bytes if max_stderr_bytes is None else max_stderr_bytes
256
+ if (
257
+ isinstance(stderr_limit, bool)
258
+ or not isinstance(stderr_limit, int)
259
+ or stderr_limit < 0
260
+ ):
261
+ raise RunnerError("runner stderr output bound must not be negative")
237
262
  if input_text is not None and input_path is not None:
238
263
  raise RunnerError("runner input_text and input_path are mutually exclusive")
239
264
  input_file = None
@@ -241,7 +266,9 @@ def run_process(
241
266
  if not isinstance(input_path, pathlib.Path):
242
267
  input_path = pathlib.Path(input_path)
243
268
  if path_is_link_or_reparse(input_path):
244
- raise RunnerError("runner input file must not be a symlink or reparse point")
269
+ raise RunnerError(
270
+ "runner input file must not be a symlink or reparse point"
271
+ )
245
272
  descriptor = -1
246
273
  try:
247
274
  # O_NONBLOCK prevents opening a FIFO from waiting for a writer;
@@ -292,7 +319,9 @@ def run_process(
292
319
  except OSError as exc:
293
320
  if input_file is not None:
294
321
  input_file.close()
295
- raise RunnerError("runner process could not be started", details=type(exc).__name__) from exc
322
+ raise RunnerError(
323
+ "runner process could not be started", details=type(exc).__name__
324
+ ) from exc
296
325
 
297
326
  if process.stdout is None or process.stderr is None or process.stdin is None:
298
327
  _stop_process(process, time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS)
@@ -306,20 +335,39 @@ def run_process(
306
335
  stderr_lock = threading.Lock()
307
336
  stdout_truncated = [False]
308
337
  stderr_truncated = [False]
338
+ output_limit_reached = threading.Event()
309
339
  stdout_thread = threading.Thread(
310
340
  target=_read_bounded,
311
- args=(process.stdout, max_output_bytes, stdout, stdout_truncated, stdout_lock),
341
+ args=(
342
+ process.stdout,
343
+ max_output_bytes,
344
+ stdout,
345
+ stdout_truncated,
346
+ stdout_lock,
347
+ output_limit_reached,
348
+ ),
312
349
  daemon=True,
313
350
  )
314
351
  stderr_thread = threading.Thread(
315
352
  target=_read_bounded,
316
- args=(process.stderr, max_output_bytes, stderr, stderr_truncated, stderr_lock),
353
+ args=(
354
+ process.stderr,
355
+ stderr_limit,
356
+ stderr,
357
+ stderr_truncated,
358
+ stderr_lock,
359
+ output_limit_reached,
360
+ ),
317
361
  daemon=True,
318
362
  )
319
363
  stdout_thread.start()
320
364
  stderr_thread.start()
321
365
 
322
- input_bytes = None if input_text is None else input_text.encode("utf-8", errors="surrogateescape")
366
+ input_bytes = (
367
+ None
368
+ if input_text is None
369
+ else input_text.encode("utf-8", errors="surrogateescape")
370
+ )
323
371
 
324
372
  def write_input() -> None:
325
373
  try:
@@ -350,25 +398,57 @@ def run_process(
350
398
 
351
399
  input_thread = threading.Thread(target=write_input, daemon=True)
352
400
  input_thread.start()
401
+ process_exited = threading.Event()
402
+ process_returncode: list[int | None] = [None]
403
+
404
+ def wait_for_process_exit() -> None:
405
+ process_returncode[0] = process.wait()
406
+ process_exited.set()
407
+
408
+ process_wait_thread = threading.Thread(target=wait_for_process_exit, daemon=True)
409
+ process_wait_thread.start()
353
410
  returncode: int | None = None
354
411
  timeout_cause: subprocess.TimeoutExpired | None = None
412
+ output_limit_cause = False
355
413
  try:
356
- returncode = process.wait(timeout=timeout_seconds)
357
- except subprocess.TimeoutExpired as exc:
358
- timeout_cause = exc
414
+ deadline = time.monotonic() + timeout_seconds
415
+ while True:
416
+ remaining = deadline - time.monotonic()
417
+ if remaining <= 0:
418
+ timeout_cause = subprocess.TimeoutExpired(list(argv), timeout_seconds)
419
+ break
420
+ if output_limit_reached.is_set():
421
+ # A short grace period preserves the natural exit status for
422
+ # commands that wrote their final bounded chunk and were
423
+ # already exiting. A still-live child is stopped below
424
+ # rather than allowed to drain discarded output until timeout.
425
+ if process_exited.wait(timeout=min(remaining, 0.05)):
426
+ returncode = process_returncode[0]
427
+ else:
428
+ output_limit_cause = True
429
+ break
430
+ if process_exited.wait(timeout=min(remaining, 0.05)):
431
+ returncode = process_returncode[0]
432
+ break
359
433
  finally:
360
434
  cleanup_deadline = time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS
361
435
  _stop_process(process, cleanup_deadline)
362
436
  _finish_capture(
363
437
  (process.stdin, process.stdout, process.stderr),
364
- (input_thread, stdout_thread, stderr_thread),
438
+ (input_thread, stdout_thread, stderr_thread, process_wait_thread),
365
439
  cleanup_deadline,
366
440
  )
367
441
 
368
442
  if returncode is None:
369
- returncode = process.poll()
443
+ returncode = (
444
+ process_returncode[0]
445
+ if process_returncode[0] is not None
446
+ else process.poll()
447
+ )
370
448
  if timeout_cause is not None:
371
- timeout_returncode = returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
449
+ timeout_returncode = (
450
+ returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
451
+ )
372
452
  process_result = _captured_result(
373
453
  timeout_returncode,
374
454
  stdout,
@@ -393,7 +473,7 @@ def run_process(
393
473
  stdout_lock,
394
474
  stderr_lock,
395
475
  )
396
- if returncode < 0:
476
+ if returncode < 0 and not output_limit_cause:
397
477
  error = RunnerSignalError(
398
478
  "runner process terminated by signal",
399
479
  details=str(-returncode),
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import importlib
6
+ import threading
6
7
  from dataclasses import dataclass
7
8
  from types import ModuleType
8
9
  from typing import Callable, Mapping
@@ -52,7 +53,9 @@ class LazyRunnerSpec:
52
53
  details=type(exc).__name__,
53
54
  ) from exc
54
55
  if not callable(getattr(runner, "run", None)):
55
- raise RunnerAdapterUnavailableError("selected runner does not implement run(request)")
56
+ raise RunnerAdapterUnavailableError(
57
+ "selected runner does not implement run(request)"
58
+ )
56
59
  return runner
57
60
 
58
61
 
@@ -67,17 +70,25 @@ _DEFAULT_SPECS = {
67
70
  class RunnerRegistry:
68
71
  """A fixed registry; it intentionally has no runtime plugin registration API."""
69
72
 
70
- def __init__(self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None) -> None:
73
+ def __init__(
74
+ self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None
75
+ ) -> None:
71
76
  selected = _DEFAULT_SPECS if specs is None else dict(specs)
72
77
  unknown = set(selected) - set(KNOWN_RUNNER_TYPES)
73
78
  if unknown:
74
79
  raise RunnerContractError(
75
- "runner registry contains unsupported adapter types: " + ", ".join(sorted(unknown))
80
+ "runner registry contains unsupported adapter types: "
81
+ + ", ".join(sorted(unknown))
76
82
  )
77
83
  if set(selected) != set(KNOWN_RUNNER_TYPES):
78
- raise RunnerContractError("runner registry must contain all four known adapter types")
84
+ raise RunnerContractError(
85
+ "runner registry must contain all four known adapter types"
86
+ )
79
87
  self._specs = dict(selected)
80
88
  self._loaded: dict[str, Runner] = {}
89
+ self._load_locks = {
90
+ runner_type: threading.Lock() for runner_type in KNOWN_RUNNER_TYPES
91
+ }
81
92
 
82
93
  @property
83
94
  def known_types(self) -> tuple[str, ...]:
@@ -86,10 +97,19 @@ class RunnerRegistry:
86
97
  def get(self, runner_type: str) -> Runner:
87
98
  if runner_type not in self._specs:
88
99
  raise RunnerContractError(f"unknown runner type: {runner_type!r}")
89
- if runner_type not in self._loaded:
90
- spec = self._specs[runner_type]
91
- self._loaded[runner_type] = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
92
- return self._loaded[runner_type]
100
+ runner = self._loaded.get(runner_type)
101
+ if runner is not None:
102
+ return runner
103
+ # Initialization is serialized only for the selected adapter. Other
104
+ # runner types may load concurrently, which matters for capability
105
+ # probes such as Claude's --help check.
106
+ with self._load_locks[runner_type]:
107
+ runner = self._loaded.get(runner_type)
108
+ if runner is None:
109
+ spec = self._specs[runner_type]
110
+ runner = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
111
+ self._loaded[runner_type] = runner
112
+ return runner
93
113
 
94
114
  def __contains__(self, runner_type: object) -> bool:
95
115
  return runner_type in self._specs
@@ -98,7 +118,9 @@ class RunnerRegistry:
98
118
  DEFAULT_RUNNER_REGISTRY = RunnerRegistry()
99
119
 
100
120
 
101
- def get_runner(runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY) -> Runner:
121
+ def get_runner(
122
+ runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY
123
+ ) -> Runner:
102
124
  """Resolve one of the four static adapter types lazily."""
103
125
 
104
126
  return registry.get(runner_type)
@@ -19,7 +19,13 @@ from typing import Any
19
19
 
20
20
  from ..artifacts import ArtifactStore
21
21
  from ..paths import path_is_link_or_reparse
22
- from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
22
+ from ..types import (
23
+ DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
24
+ HookError,
25
+ ModuleRuntimeState,
26
+ RuntimeContext,
27
+ StepConfig,
28
+ )
23
29
  from .runners.contracts import bounded_diagnostic, redact_diagnostic
24
30
  from .runners.process import (
25
31
  DEFAULT_MAX_OUTPUT_BYTES,
@@ -27,7 +33,6 @@ from .runners.process import (
27
33
  run_process,
28
34
  )
29
35
 
30
- DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
31
36
  STEP_COMMAND_STDOUT_ARTIFACT = "stdout.txt"
32
37
  STEP_COMMAND_STDERR_ARTIFACT = "stderr.txt"
33
38
  STEP_COMMAND_RESULT_ARTIFACT = "result.json"
@@ -74,7 +79,10 @@ class StepCommandResult:
74
79
  raise TypeError("step command returncode must be an integer")
75
80
  if not isinstance(self.stdout, bytes) or not isinstance(self.stderr, bytes):
76
81
  raise TypeError("step command streams must be bytes")
77
- if type(self.stdout_truncated) is not bool or type(self.stderr_truncated) is not bool:
82
+ if (
83
+ type(self.stdout_truncated) is not bool
84
+ or type(self.stderr_truncated) is not bool
85
+ ):
78
86
  raise TypeError("step command truncation flags must be booleans")
79
87
 
80
88
  def __repr__(self) -> str:
@@ -113,18 +121,24 @@ def _validated_inputs(
113
121
  if inputs is None:
114
122
  return {}
115
123
  if not isinstance(inputs, Mapping):
116
- raise StepCommandError("step command inputs must be a logical-reference mapping")
124
+ raise StepCommandError(
125
+ "step command inputs must be a logical-reference mapping"
126
+ )
117
127
  normalized: dict[str, pathlib.Path] = {}
118
128
  for logical_ref, path in inputs.items():
119
129
  if not isinstance(logical_ref, str) or not logical_ref:
120
- raise StepCommandError("step command input references must be non-empty strings")
130
+ raise StepCommandError(
131
+ "step command input references must be non-empty strings"
132
+ )
121
133
  if not isinstance(path, pathlib.Path):
122
134
  path = pathlib.Path(path)
123
135
  try:
124
136
  resolved = path.resolve(strict=True)
125
137
  metadata = resolved.stat()
126
138
  except (OSError, RuntimeError) as exc:
127
- raise StepCommandError("step command input artifact could not be opened") from exc
139
+ raise StepCommandError(
140
+ "step command input artifact could not be opened"
141
+ ) from exc
128
142
  if path_is_link_or_reparse(path) or not resolved.is_file() or not metadata:
129
143
  raise StepCommandError("step command input artifact must be a regular file")
130
144
  normalized[logical_ref] = resolved
@@ -155,7 +169,9 @@ def _substitute_argv(
155
169
  )
156
170
  rendered.append(str(inputs[logical_ref]))
157
171
  else:
158
- raise StepCommandError(f"unknown step command placeholder in argument {index}")
172
+ raise StepCommandError(
173
+ f"unknown step command placeholder in argument {index}"
174
+ )
159
175
  continue
160
176
 
161
177
  if _RECOGNIZED_TOKEN.search(argument):
@@ -178,12 +194,16 @@ def resolve_step_command_argv(
178
194
  try:
179
195
  root = pathlib.Path(repo_root).resolve(strict=True)
180
196
  except (OSError, RuntimeError) as exc:
181
- raise StepCommandError("step command repository root could not be resolved") from exc
197
+ raise StepCommandError(
198
+ "step command repository root could not be resolved"
199
+ ) from exc
182
200
  if not root.is_dir():
183
201
  raise StepCommandError("step command repository root must be a directory")
184
202
  executable = python_executable or sys.executable
185
203
  if not isinstance(executable, str) or not executable or "\x00" in executable:
186
- raise StepCommandError("step command Python interpreter must be a non-empty NUL-free string")
204
+ raise StepCommandError(
205
+ "step command Python interpreter must be a non-empty NUL-free string"
206
+ )
187
207
  return _substitute_argv(
188
208
  command,
189
209
  repo_root=root,
@@ -222,7 +242,9 @@ def _validate_utf8(result: StepCommandResult) -> None:
222
242
  try:
223
243
  stream.decode("utf-8")
224
244
  except UnicodeDecodeError as exc:
225
- error = StepCommandEncodingError(f"step command emitted invalid UTF-8 on {name}")
245
+ error = StepCommandEncodingError(
246
+ f"step command emitted invalid UTF-8 on {name}"
247
+ )
226
248
  error._step_command_result = result
227
249
  raise error from exc
228
250
 
@@ -245,17 +267,27 @@ def run_step_command(
245
267
  their private ``_process_result`` attribute for the persistence helper.
246
268
  """
247
269
 
248
- if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)):
270
+ if isinstance(timeout_seconds, bool) or not isinstance(
271
+ timeout_seconds, (int, float)
272
+ ):
249
273
  raise StepCommandError("step command timeout must be a finite positive number")
250
274
  if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
251
275
  raise StepCommandError("step command timeout must be a finite positive number")
252
- if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes < 0:
253
- raise StepCommandError("step command output bound must be a non-negative integer")
276
+ if (
277
+ isinstance(max_output_bytes, bool)
278
+ or not isinstance(max_output_bytes, int)
279
+ or max_output_bytes < 0
280
+ ):
281
+ raise StepCommandError(
282
+ "step command output bound must be a non-negative integer"
283
+ )
254
284
 
255
285
  input_paths = _validated_inputs(inputs)
256
286
  if stdin is not None:
257
287
  if not isinstance(stdin, str) or stdin not in input_paths:
258
- raise StepCommandError("step command stdin must exactly match a declared input")
288
+ raise StepCommandError(
289
+ "step command stdin must exactly match a declared input"
290
+ )
259
291
  input_path = input_paths[stdin]
260
292
  else:
261
293
  input_path = None
@@ -283,7 +315,9 @@ def run_step_command(
283
315
  )
284
316
  if truncated
285
317
  )
286
- error = StepCommandTruncatedError(f"step command {streams} exceeded its capture limit")
318
+ error = StepCommandTruncatedError(
319
+ f"step command {streams} exceeded its capture limit"
320
+ )
287
321
  error._step_command_result = result
288
322
  raise error
289
323
  _validate_utf8(result)
@@ -307,7 +341,10 @@ def _assert_message(result: StepCommandResult) -> str:
307
341
  stderr = result.stderr.decode("utf-8", errors="surrogateescape")
308
342
  combined = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
309
343
  safe = redact_diagnostic(combined, secrets=_environment_secrets())
310
- return bounded_diagnostic(safe, max_chars=1_200) or f"command exited with status {result.returncode}"
344
+ return (
345
+ bounded_diagnostic(safe, max_chars=1_200)
346
+ or f"command exited with status {result.returncode}"
347
+ )
311
348
 
312
349
 
313
350
  def step_command_result_payload(
@@ -392,7 +429,9 @@ def execute_step_command(
392
429
  """
393
430
 
394
431
  if step.type not in {"exec", "assert"}:
395
- raise StepCommandError("step command implementation requires an exec or assert step")
432
+ raise StepCommandError(
433
+ "step command implementation requires an exec or assert step"
434
+ )
396
435
  if isinstance(inputs, Mapping):
397
436
  input_map = dict(inputs)
398
437
  declared_references = set(step.inputs)
@@ -402,7 +441,9 @@ def execute_step_command(
402
441
  )
403
442
  else:
404
443
  if len(inputs) != len(step.inputs):
405
- raise StepCommandError("step command input paths do not match declared inputs")
444
+ raise StepCommandError(
445
+ "step command input paths do not match declared inputs"
446
+ )
406
447
  input_map = dict(zip(step.inputs, inputs))
407
448
  store = artifacts or ArtifactStore(context.run_dir)
408
449
  store.prepare()
@@ -439,9 +480,13 @@ def execute_step_command(
439
480
  raise
440
481
 
441
482
  payload = step_command_result_payload(process_result, step_type=step.type)
442
- persisted = _persist_process_result(store, state, step, process_result, payload=payload)
483
+ persisted = _persist_process_result(
484
+ store, state, step, process_result, payload=payload
485
+ )
443
486
  if step.type == "exec" and process_result.returncode != 0:
444
- error = StepCommandExecutionError("step exec command returned a non-zero status")
487
+ error = StepCommandExecutionError(
488
+ "step exec command returned a non-zero status"
489
+ )
445
490
  error._step_command_result = process_result
446
491
  error._step_command_persisted = persisted
447
492
  raise error