skillflow-py 1.4.2__tar.gz → 1.4.3__tar.gz

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 (94) hide show
  1. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/PKG-INFO +1 -1
  2. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/pyproject.toml +1 -1
  3. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/core.py +42 -23
  4. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow_py.egg-info/PKG-INFO +1 -1
  5. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_core.py +51 -0
  6. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_recovery.py +67 -0
  7. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/LICENSE +0 -0
  8. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/README.md +0 -0
  9. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/setup.cfg +0 -0
  10. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/__init__.py +0 -0
  11. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/agent_registry.py +0 -0
  12. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/context.py +0 -0
  13. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/convert_cli.py +0 -0
  14. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/exceptions.py +0 -0
  15. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/graph.py +0 -0
  16. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/notifications.py +0 -0
  17. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/outbox.py +0 -0
  18. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/linter/__init__.py +0 -0
  19. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/linter/cli.py +0 -0
  20. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  21. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  22. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  23. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  24. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  25. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  26. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  27. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  28. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  29. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  30. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  31. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  32. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  33. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/read_tools.py +0 -0
  34. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/recovery.py +0 -0
  35. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/run_cli.py +0 -0
  36. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/schema.py +0 -0
  37. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/step_validation.py +0 -0
  38. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tool_loader.py +0 -0
  39. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/__init__.py +0 -0
  40. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/dir_tree/impl.py +0 -0
  41. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  42. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/draft_commit/impl.py +0 -0
  43. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  44. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/file_exists/impl.py +0 -0
  45. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  46. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  47. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  48. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/json_schema/impl.py +0 -0
  49. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  50. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/lint/impl.py +0 -0
  51. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/lint/tool.yaml +0 -0
  52. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/list_tree/impl.py +0 -0
  53. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  54. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/notify/impl.py +0 -0
  55. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/notify/tool.yaml +0 -0
  56. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/pytest/impl.py +0 -0
  57. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/pytest/tool.yaml +0 -0
  58. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/read_file/impl.py +0 -0
  59. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/read_file/tool.yaml +0 -0
  60. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/repo_apply/impl.py +0 -0
  61. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
  62. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/repo_validate/impl.py +0 -0
  63. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  64. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/write/impl.py +0 -0
  65. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/tools/write/tool.yaml +0 -0
  66. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/validation.py +0 -0
  67. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/workspace.py +0 -0
  68. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow/write_tools.py +0 -0
  69. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
  70. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  71. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  72. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow_py.egg-info/requires.txt +0 -0
  73. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/src/skillflow_py.egg-info/top_level.txt +0 -0
  74. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_context.py +0 -0
  75. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_exceptions.py +0 -0
  76. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_existing_repo_apply.py +0 -0
  77. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_git_sync_pre.py +0 -0
  78. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_graph.py +0 -0
  79. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_integration.py +0 -0
  80. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_integration_configs.py +0 -0
  81. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_migration_regression.py +0 -0
  82. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_noop_floor_fix.py +0 -0
  83. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_notifications.py +0 -0
  84. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_outbox.py +0 -0
  85. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_read_tools_exec.py +0 -0
  86. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_read_tools_label.py +0 -0
  87. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_regression.py +0 -0
  88. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_step_validation.py +0 -0
  89. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_tool_loader.py +0 -0
  90. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_tool_lock_contention.py +0 -0
  91. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_tools.py +0 -0
  92. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_validation.py +0 -0
  93. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_workspace.py +0 -0
  94. {skillflow_py-1.4.2 → skillflow_py-1.4.3}/tests/test_write_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skillflow-py"
7
- version = "1.4.2"
7
+ version = "1.4.3"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1286,29 +1286,44 @@ class SkillFlow:
1286
1286
  hook_name: str, items: list) -> dict:
1287
1287
  """Run a list of tool hooks in order (e.g. repo_apply then repo_delete).
1288
1288
 
1289
- Each item is a ``{'tool', 'params', 'on_failure'?}`` dict executed via
1290
- :meth:`_execute_tool_hook` (full variable resolution + project_root
1291
- injection). Per-item ``on_failure`` is honored: ``warn``/``skip`` log and
1292
- continue to the next item; ``retry``/``fail`` stop the sequence and
1293
- bubble up (carrying the item's ``on_failure``) so the caller applies the
1294
- right step-level outcome.
1289
+ Each item is a ``{'tool', 'params', 'on_failure'?, 'max_retries'?}`` dict
1290
+ executed via :meth:`_execute_tool_hook` (full variable resolution +
1291
+ project_root injection). Per-item policy is honored IN PLACE:
1292
+ ``on_failure: retry`` re-runs THAT item up to its own ``max_retries``
1293
+ (without re-running earlier, already-succeeded items or the agent step);
1294
+ ``warn``/``skip`` log and continue to the next item; ``fail`` (or a retry
1295
+ item that exhausts its retries) stops the sequence and fails the step.
1296
+ The sequence never bubbles ``retry`` to the caller — that would reset the
1297
+ whole agent step and re-execute earlier, already-committed items.
1295
1298
  """
1296
1299
  detail_files = None
1297
1300
  for item in items:
1298
1301
  if not (isinstance(item, dict) and "tool" in item):
1299
1302
  return {"passed": False,
1300
1303
  "error": f"Invalid tool hook in '{hook_name}': {item!r}"}
1301
- res = self._execute_tool_hook(token, node, hook_name, item)
1304
+ on_failure = item.get("on_failure", "fail")
1305
+ max_retries = int(item.get("max_retries", 0) or 0)
1306
+ res = {"passed": False}
1307
+ for attempt in range(max_retries + 1):
1308
+ res = self._execute_tool_hook(token, node, hook_name, item)
1309
+ if res.get("passed", True):
1310
+ break
1311
+ if on_failure == "retry" and attempt < max_retries:
1312
+ # Retry THIS item in place — do not re-run earlier items.
1313
+ self._emit_lifecycle_event(
1314
+ token, hook_name, "retry", res.get("error", ""))
1315
+ continue
1316
+ break
1302
1317
  if not res.get("passed", True):
1303
- on_failure = item.get("on_failure", "fail")
1304
1318
  if on_failure in ("warn", "skip"):
1305
1319
  self._emit_lifecycle_event(
1306
1320
  token, hook_name,
1307
1321
  "warned" if on_failure == "warn" else "skipped",
1308
1322
  res.get("error", ""))
1309
1323
  continue
1310
- # retry / fail: stop and let the caller apply the step outcome.
1311
- return {**res, "on_failure": on_failure}
1324
+ # 'fail', or a 'retry' item that exhausted max_retries fail the
1325
+ # step (never bubble 'retry', which re-runs the whole sequence).
1326
+ return {**res, "on_failure": "fail"}
1312
1327
  files = res.get("files")
1313
1328
  if isinstance(files, list):
1314
1329
  detail_files = files
@@ -2326,10 +2341,12 @@ class SkillFlow:
2326
2341
  so it retries promptly instead of stalling until its claim times out.
2327
2342
 
2328
2343
  Capped at 3 crashes: a tool that crashes deterministically is broken,
2329
- not transient, so relaunching it every tick just rampages until the
2330
- host's step-count valve trips. The SF-20 stale-recovery cap does NOT
2331
- cover this path (a crash reopens directly, never via
2332
- recover_stale_claims), so track the count here and fail after 3.
2344
+ not transient. On the 3rd crash the RUN is failed (not just the step)
2345
+ marking only the step failed + current_node=NULL would let advance_run
2346
+ re-resolve back to the tool and open a FRESH instance with a reset
2347
+ counter, so the crash loop never actually stopped (only the host's
2348
+ step-count valve caught it). The SF-20 stale-recovery cap does NOT cover
2349
+ this path (a crash reopens directly, never via recover_stale_claims).
2333
2350
  """
2334
2351
  with self._tx() as conn:
2335
2352
  row = conn.execute(
@@ -2353,11 +2370,9 @@ class SkillFlow:
2353
2370
  "claimed_by = NULL, updated_at = datetime('now') WHERE id = ?",
2354
2371
  (error_msg, row["id"]),
2355
2372
  )
2356
- conn.execute(
2357
- "UPDATE skillflow_runs SET current_node = NULL, "
2358
- "updated_at = datetime('now') WHERE id = ?",
2359
- (run_id,),
2360
- )
2373
+ # Fail the RUN so advance_run returns None instead of re-resolving
2374
+ # the predecessor's transition and opening a fresh tool instance.
2375
+ self._fail_run_in_tx(conn, run_id, error_msg)
2361
2376
  self.notifications.publish_sync(
2362
2377
  "step_failed",
2363
2378
  {"run_id": run_id, "step_id": step_id,
@@ -2984,10 +2999,14 @@ class SkillFlow:
2984
2999
  try:
2985
3000
  node = self._get_resolver_for_run(
2986
3001
  row["run_id"]).get_node(row["step_id"])
2987
- if (node and node.step_type == "tool"
2988
- and node.timeout_seconds
2989
- and node.timeout_seconds > window):
2990
- window = float(node.timeout_seconds)
3002
+ if node and node.step_type == "tool":
3003
+ if node.timeout_seconds == 0:
3004
+ # 0 = "no timeout": a live tool may run arbitrarily
3005
+ # long, so it is NEVER stale — reclaiming it would
3006
+ # relaunch it concurrently with itself (the rampage).
3007
+ continue
3008
+ if node.timeout_seconds > window:
3009
+ window = float(node.timeout_seconds)
2991
3010
  except Exception:
2992
3011
  pass
2993
3012
  threshold = time.strftime(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -1247,6 +1247,57 @@ def test_on_deliver_list_order_and_on_failure(sf: SkillFlow, tmp_path: Path,
1247
1247
  assert calls == []
1248
1248
 
1249
1249
 
1250
+ def test_on_deliver_item_retries_in_place(sf: SkillFlow, tmp_path: Path,
1251
+ monkeypatch):
1252
+ """A failing on_deliver item with on_failure=retry re-runs THAT item up to
1253
+ its own max_retries IN PLACE; earlier items are not re-run, and the sequence
1254
+ never bubbles 'retry' to the caller (which would reset the whole step)."""
1255
+ sf._tool_loader = ToolLoader(
1256
+ Path(__file__).parent.parent / "src" / "skillflow" / "tools")
1257
+ sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
1258
+ projects_base=str(tmp_path / "proj"))
1259
+ node = StepNode(id="s1", step_type="agent", output_mode="content",
1260
+ output_fixed={"out": "r.md"}, transitions=[Transition(to=None)])
1261
+ sf.register_graph(PipelineGraph(name="seq", begin="s1", steps=[node]))
1262
+ rid = sf.create_run("seq", {"project_id": "pid"})
1263
+ sf.start_run(rid)
1264
+ sf.advance_run(rid)
1265
+ token = sf.claim_next_step(rid).token
1266
+
1267
+ calls: list = []
1268
+
1269
+ # 'flaky' fails its first 2 attempts, succeeds on the 3rd.
1270
+ def fake(tok, nd, hook_name, spec):
1271
+ calls.append(spec["tool"])
1272
+ if spec["tool"] == "flaky":
1273
+ return {"passed": calls.count("flaky") >= 3}
1274
+ return {"passed": True}
1275
+ monkeypatch.setattr(sf, "_execute_tool_hook", fake)
1276
+
1277
+ r = sf._execute_lifecycle_hook(token, node, "on_deliver", [
1278
+ {"tool": "repo_apply"},
1279
+ {"tool": "flaky", "on_failure": "retry", "max_retries": 3},
1280
+ ])
1281
+ # repo_apply ran ONCE (not re-run while flaky retried); flaky retried in place.
1282
+ assert calls == ["repo_apply", "flaky", "flaky", "flaky"]
1283
+ assert r["passed"] is True
1284
+
1285
+ # Exhausted retries → fail the STEP, never bubble 'retry'.
1286
+ calls.clear()
1287
+
1288
+ def always_fail(tok, nd, hook_name, spec):
1289
+ calls.append(spec["tool"])
1290
+ return {"passed": spec["tool"] != "boom"}
1291
+ monkeypatch.setattr(sf, "_execute_tool_hook", always_fail)
1292
+ r = sf._execute_lifecycle_hook(token, node, "on_deliver", [
1293
+ {"tool": "repo_apply"},
1294
+ {"tool": "boom", "on_failure": "retry", "max_retries": 2},
1295
+ ])
1296
+ assert calls == ["repo_apply", "boom", "boom", "boom"] # 1 initial + 2 retries
1297
+ assert r["passed"] is False
1298
+ assert r.get("on_failure") == "fail" # NOT "retry" — no whole-step re-run
1299
+
1300
+
1250
1301
  # ── Durable run trace ─────────────────────────────────────────────────
1251
1302
 
1252
1303
  def test_trace_append_and_get(sf: SkillFlow):
@@ -1,17 +1,84 @@
1
1
  """Unit tests for recovery.py."""
2
2
 
3
3
  import time
4
+ from pathlib import Path
4
5
 
5
6
  import pytest
6
7
 
7
8
  from skillflow.core import SkillFlow
8
9
  from skillflow.graph import PipelineGraph, StepNode, Transition
10
+ from skillflow.tool_loader import ToolLoader
11
+ from skillflow.workspace import WorkspaceManager
12
+
13
+ _REAL_TOOLS = Path(__file__).parent.parent / "src" / "skillflow" / "tools"
9
14
 
10
15
 
11
16
  def _agent(id: str, transitions=None):
12
17
  return StepNode(id=id, step_type="agent", transitions=transitions or [])
13
18
 
14
19
 
20
+ def test_recover_never_reclaims_tool_with_zero_timeout(sf: SkillFlow):
21
+ """A tool node with timeout_seconds=0 ('no timeout') is never reclaimed,
22
+ however old the claim — reclaiming a live no-timeout tool would relaunch it
23
+ concurrently with itself (the rampage)."""
24
+ graph = PipelineGraph(
25
+ name="test", begin="a",
26
+ steps=[StepNode(id="a", step_type="tool", tool_name="write",
27
+ timeout_seconds=0, transitions=[])],
28
+ )
29
+ sf.register_graph(graph)
30
+ run_id = sf.create_run("test")
31
+ sf.start_run(run_id)
32
+ long_ago = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 99999))
33
+ with sf._lock:
34
+ sf._conn.execute(
35
+ "INSERT INTO skillflow_steps (run_id, step_id, step_config_json, "
36
+ "inputs_json, max_retries, status, claimed_at, created_at, updated_at) "
37
+ "VALUES (?, 'a', '{}', '{}', 3, 'claimed', ?, "
38
+ "datetime('now'), datetime('now'))",
39
+ (run_id, long_ago))
40
+ sf._conn.commit()
41
+ # Claimed 99999s ago, but timeout_seconds=0 → never stale.
42
+ assert sf.recover_stale_claims(stale_threshold_seconds=60) == []
43
+ st = sf._conn.execute(
44
+ "SELECT status FROM skillflow_steps WHERE run_id=? AND step_id='a' "
45
+ "ORDER BY id DESC LIMIT 1", (run_id,)).fetchone()
46
+ assert st["status"] == "claimed"
47
+
48
+
49
+ def test_tool_crash_fails_run_after_three(sf: SkillFlow, tmp_path,
50
+ monkeypatch):
51
+ """A tool that crashes on EVERY execution fails the RUN after 3 crashes,
52
+ instead of looping forever via advance_run re-resolving into fresh instances
53
+ (regression: the per-instance _tool_reopen_count was reset each time
54
+ _claim_tool_step_in_tx opened a new instance)."""
55
+ sf._tool_loader = ToolLoader(_REAL_TOOLS)
56
+ sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
57
+ projects_base=str(tmp_path / "proj"))
58
+ node = StepNode(id="boom", step_type="tool", tool_name="write",
59
+ transitions=[Transition(to=None)])
60
+ sf.register_graph(PipelineGraph(name="crash", begin="boom", steps=[node]))
61
+ run_id = sf.create_run("crash", {"project_id": "p"})
62
+ sf.start_run(run_id)
63
+
64
+ def _boom(*a, **k):
65
+ raise RuntimeError("boom")
66
+ monkeypatch.setattr(sf, "_execute_tool_inline", _boom)
67
+
68
+ for _ in range(25):
69
+ try:
70
+ sf.advance_run(run_id) # tool fast-path reopens then re-raises
71
+ except RuntimeError:
72
+ pass
73
+ if sf.get_run(run_id)["status"] == "failed":
74
+ break
75
+ assert sf.get_run(run_id)["status"] == "failed"
76
+ n = sf._conn.execute(
77
+ "SELECT COUNT(*) c FROM skillflow_steps WHERE run_id=? AND step_id='boom'",
78
+ (run_id,)).fetchone()["c"]
79
+ assert n == 1, f"crash should fail the run after 1 instance x3, got {n}"
80
+
81
+
15
82
  def test_recover_respects_tool_timeout_seconds(sf: SkillFlow):
16
83
  """A TOOL step claimed longer than the flat threshold but WITHIN its
17
84
  timeout_seconds is NOT reclaimed — a slow-but-alive tool (run_tests,
File without changes
File without changes
File without changes