skillflow-py 1.4.0__tar.gz → 1.4.2__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 (95) hide show
  1. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/PKG-INFO +1 -1
  2. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/pyproject.toml +1 -1
  3. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/core.py +129 -16
  4. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow_py.egg-info/PKG-INFO +1 -1
  5. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_core.py +105 -0
  6. skillflow_py-1.4.2/tests/test_recovery.py +184 -0
  7. skillflow_py-1.4.0/tests/test_recovery.py +0 -99
  8. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/LICENSE +0 -0
  9. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/README.md +0 -0
  10. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/setup.cfg +0 -0
  11. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/__init__.py +0 -0
  12. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/agent_registry.py +0 -0
  13. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/context.py +0 -0
  14. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/convert_cli.py +0 -0
  15. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/exceptions.py +0 -0
  16. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/graph.py +0 -0
  17. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/notifications.py +0 -0
  18. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/outbox.py +0 -0
  19. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/linter/__init__.py +0 -0
  20. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/linter/cli.py +0 -0
  21. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  22. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  23. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  24. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  25. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  26. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  27. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  28. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  29. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  30. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  31. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  32. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  33. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  34. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/read_tools.py +0 -0
  35. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/recovery.py +0 -0
  36. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/run_cli.py +0 -0
  37. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/schema.py +0 -0
  38. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/step_validation.py +0 -0
  39. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tool_loader.py +0 -0
  40. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/__init__.py +0 -0
  41. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/dir_tree/impl.py +0 -0
  42. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  43. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/draft_commit/impl.py +0 -0
  44. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  45. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/file_exists/impl.py +0 -0
  46. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  47. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  48. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  49. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/json_schema/impl.py +0 -0
  50. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  51. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/lint/impl.py +0 -0
  52. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/lint/tool.yaml +0 -0
  53. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/list_tree/impl.py +0 -0
  54. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  55. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/notify/impl.py +0 -0
  56. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/notify/tool.yaml +0 -0
  57. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/pytest/impl.py +0 -0
  58. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/pytest/tool.yaml +0 -0
  59. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/read_file/impl.py +0 -0
  60. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/read_file/tool.yaml +0 -0
  61. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/repo_apply/impl.py +0 -0
  62. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
  63. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/repo_validate/impl.py +0 -0
  64. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  65. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/write/impl.py +0 -0
  66. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/tools/write/tool.yaml +0 -0
  67. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/validation.py +0 -0
  68. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/workspace.py +0 -0
  69. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow/write_tools.py +0 -0
  70. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
  71. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  72. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  73. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow_py.egg-info/requires.txt +0 -0
  74. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/src/skillflow_py.egg-info/top_level.txt +0 -0
  75. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_context.py +0 -0
  76. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_exceptions.py +0 -0
  77. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_existing_repo_apply.py +0 -0
  78. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_git_sync_pre.py +0 -0
  79. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_graph.py +0 -0
  80. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_integration.py +0 -0
  81. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_integration_configs.py +0 -0
  82. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_migration_regression.py +0 -0
  83. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_noop_floor_fix.py +0 -0
  84. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_notifications.py +0 -0
  85. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_outbox.py +0 -0
  86. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_read_tools_exec.py +0 -0
  87. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_read_tools_label.py +0 -0
  88. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_regression.py +0 -0
  89. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_step_validation.py +0 -0
  90. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_tool_loader.py +0 -0
  91. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_tool_lock_contention.py +0 -0
  92. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_tools.py +0 -0
  93. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_validation.py +0 -0
  94. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/tests/test_workspace.py +0 -0
  95. {skillflow_py-1.4.0 → skillflow_py-1.4.2}/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.0
3
+ Version: 1.4.2
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.0"
7
+ version = "1.4.2"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1057,7 +1057,11 @@ class SkillFlow:
1057
1057
 
1058
1058
  if not hook_result.get("passed", False):
1059
1059
  error = hook_result.get("error", f"Lifecycle hook '{hook_name}' failed")
1060
- on_failure = hook_spec.get("on_failure", "fail") if isinstance(hook_spec, dict) else "fail"
1060
+ # A tool-hook sequence bubbles the failing item's on_failure
1061
+ # in the result; fall back to the spec-level value otherwise.
1062
+ on_failure = hook_result.get("on_failure") or (
1063
+ hook_spec.get("on_failure", "fail")
1064
+ if isinstance(hook_spec, dict) else "fail")
1061
1065
  if on_failure == "retry":
1062
1066
  self._emit_lifecycle_event(token, hook_name, "retry", error)
1063
1067
  self._handle_lifecycle_retry(token, error)
@@ -1255,19 +1259,64 @@ class SkillFlow:
1255
1259
 
1256
1260
  hook_spec can be:
1257
1261
  - A dict with 'tool' (single tool call): used for after_validate, on_deliver
1258
- - A list of dicts (multi-check): used for after_deliver
1262
+ - A list of validation specs (multi-check): used for after_deliver
1263
+ - A list of {'tool', 'params'} dicts (sequential tool hooks): used for
1264
+ on_deliver when several repo-mutating tools must run in order (e.g.
1265
+ repo_apply then repo_delete). Each runs via _execute_tool_hook, so
1266
+ $STEP_DIR is resolved and project_root injected — unlike check specs,
1267
+ which run through StepValidator and receive neither.
1259
1268
 
1260
1269
  Returns {passed: bool, error?: str}.
1261
1270
  """
1262
1271
  self._emit_lifecycle_event(token, hook_name, "started")
1263
1272
 
1264
1273
  if isinstance(hook_spec, list):
1265
- return self._execute_check_hook(token, node, hook_name, hook_spec)
1274
+ # after_deliver runs validation checks against the project repo;
1275
+ # other slots (on_deliver) run a sequence of tool hooks with full
1276
+ # variable resolution.
1277
+ if hook_name == "after_deliver":
1278
+ return self._execute_check_hook(token, node, hook_name, hook_spec)
1279
+ return self._execute_tool_hook_sequence(token, node, hook_name, hook_spec)
1266
1280
  elif isinstance(hook_spec, dict) and "tool" in hook_spec:
1267
1281
  return self._execute_tool_hook(token, node, hook_name, hook_spec)
1268
1282
  else:
1269
1283
  return {"passed": False, "error": f"Invalid hook spec for '{hook_name}'"}
1270
1284
 
1285
+ def _execute_tool_hook_sequence(self, token: ClaimToken, node: StepNode,
1286
+ hook_name: str, items: list) -> dict:
1287
+ """Run a list of tool hooks in order (e.g. repo_apply then repo_delete).
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.
1295
+ """
1296
+ detail_files = None
1297
+ for item in items:
1298
+ if not (isinstance(item, dict) and "tool" in item):
1299
+ return {"passed": False,
1300
+ "error": f"Invalid tool hook in '{hook_name}': {item!r}"}
1301
+ res = self._execute_tool_hook(token, node, hook_name, item)
1302
+ if not res.get("passed", True):
1303
+ on_failure = item.get("on_failure", "fail")
1304
+ if on_failure in ("warn", "skip"):
1305
+ self._emit_lifecycle_event(
1306
+ token, hook_name,
1307
+ "warned" if on_failure == "warn" else "skipped",
1308
+ res.get("error", ""))
1309
+ continue
1310
+ # retry / fail: stop and let the caller apply the step outcome.
1311
+ return {**res, "on_failure": on_failure}
1312
+ files = res.get("files")
1313
+ if isinstance(files, list):
1314
+ detail_files = files
1315
+ out = {"passed": True}
1316
+ if detail_files is not None:
1317
+ out["files"] = detail_files
1318
+ return out
1319
+
1271
1320
  def _execute_tool_hook(self, token: ClaimToken, node: StepNode,
1272
1321
  hook_name: str, hook_spec: dict) -> dict:
1273
1322
  """Execute a tool-type lifecycle hook (single tool call)."""
@@ -2273,15 +2322,56 @@ class SkillFlow:
2273
2322
  return inst["id"] if inst else None
2274
2323
 
2275
2324
  def _reopen_tool_step_in_tx(self, run_id: str, step_id: str) -> None:
2276
- """Release a claimed tool step back to pending after a crashed execution,
2277
- so it is retried promptly instead of stalling until its claim times out.
2325
+ """Release a claimed tool step back to pending after a CRASHED execution,
2326
+ so it retries promptly instead of stalling until its claim times out.
2327
+
2328
+ 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.
2278
2333
  """
2279
2334
  with self._tx() as conn:
2335
+ row = conn.execute(
2336
+ "SELECT id, inputs_json FROM skillflow_steps "
2337
+ "WHERE run_id = ? AND step_id = ? AND status = 'claimed' "
2338
+ "ORDER BY id DESC LIMIT 1",
2339
+ (run_id, step_id),
2340
+ ).fetchone()
2341
+ if not row:
2342
+ return
2343
+ inputs = self._deserialize(row["inputs_json"])
2344
+ reopen_count = inputs.get("_tool_reopen_count", 0) + 1
2345
+ if reopen_count >= 3:
2346
+ error_msg = (
2347
+ f"Tool step '{step_id}' crashed {reopen_count} times — "
2348
+ f"failing (likely a bug in the tool, not a transient error)."
2349
+ )
2350
+ conn.execute(
2351
+ "UPDATE skillflow_steps SET status = 'failed', "
2352
+ "version = version + 1, last_error = ?, claimed_at = NULL, "
2353
+ "claimed_by = NULL, updated_at = datetime('now') WHERE id = ?",
2354
+ (error_msg, row["id"]),
2355
+ )
2356
+ conn.execute(
2357
+ "UPDATE skillflow_runs SET current_node = NULL, "
2358
+ "updated_at = datetime('now') WHERE id = ?",
2359
+ (run_id,),
2360
+ )
2361
+ self.notifications.publish_sync(
2362
+ "step_failed",
2363
+ {"run_id": run_id, "step_id": step_id,
2364
+ "error": error_msg, "retryable": False},
2365
+ step_id=step_id, run_id=run_id,
2366
+ )
2367
+ return
2368
+ inputs["_tool_reopen_count"] = reopen_count
2280
2369
  conn.execute(
2281
2370
  "UPDATE skillflow_steps SET status = 'pending', claimed_at = NULL, "
2282
- "claimed_by = NULL, version = version + 1, updated_at = datetime('now') "
2283
- "WHERE run_id = ? AND step_id = ? AND status = 'claimed'",
2284
- (run_id, step_id),
2371
+ "claimed_by = NULL, version = version + 1, inputs_json = ?, "
2372
+ "updated_at = datetime('now') "
2373
+ "WHERE id = ? AND status = 'claimed'",
2374
+ (self._serialize(inputs), row["id"]),
2285
2375
  )
2286
2376
 
2287
2377
  def advance_run(self, run_id: str) -> str | None:
@@ -2869,18 +2959,41 @@ class SkillFlow:
2869
2959
  # ── Recovery ──────────────────────────────────────────────────
2870
2960
 
2871
2961
  def recover_stale_claims(self, stale_threshold_seconds: float = 300) -> list[str]:
2872
- threshold = time.strftime(
2873
- "%Y-%m-%dT%H:%M:%SZ",
2874
- time.gmtime(time.time() - stale_threshold_seconds),
2875
- )
2962
+ now_epoch = time.time()
2876
2963
  with self._tx() as conn:
2877
- stale = conn.execute(
2964
+ claimed = conn.execute(
2878
2965
  """
2879
- SELECT id, run_id, step_id, inputs_json FROM skillflow_steps
2880
- WHERE status = 'claimed' AND claimed_at < ?
2966
+ SELECT id, run_id, step_id, inputs_json, claimed_at
2967
+ FROM skillflow_steps WHERE status = 'claimed'
2881
2968
  """,
2882
- (threshold,),
2883
2969
  ).fetchall()
2970
+ # For a TOOL step, a claim is stale only once it is older than the
2971
+ # LONGER of the caller's flat threshold and the node's own
2972
+ # timeout_seconds. A slow-but-alive tool (e.g. run_tests, whose node
2973
+ # declares timeout_seconds=1200) must NOT be reclaimed at the flat
2974
+ # threshold — reclaiming it relaunches the tool concurrently with
2975
+ # itself, piling up mutually-starving copies (the step-5 rampage).
2976
+ # Only a claim older than the tool's max legitimate runtime is
2977
+ # presumed dead. Scoped to tool steps on purpose: agent-step
2978
+ # reclaim timing is unchanged (it feeds a separate live
2979
+ # investigation), and this is the only path prone to the rampage
2980
+ # because inline tools re-launch on every advance_run.
2981
+ stale = []
2982
+ for row in claimed:
2983
+ window = stale_threshold_seconds
2984
+ try:
2985
+ node = self._get_resolver_for_run(
2986
+ 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)
2991
+ except Exception:
2992
+ pass
2993
+ threshold = time.strftime(
2994
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_epoch - window))
2995
+ if row["claimed_at"] and row["claimed_at"] < threshold:
2996
+ stale.append(row)
2884
2997
  run_ids: set[str] = set()
2885
2998
  for row in stale:
2886
2999
  # SF-20: track stale recovery count to detect crash loops.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -1142,6 +1142,111 @@ def test_lifecycle_events_in_outbox(sf: SkillFlow, tmp_path: Path):
1142
1142
  assert step["status"] == "completed"
1143
1143
 
1144
1144
 
1145
+ def test_on_deliver_list_runs_tools_end_to_end(sf: SkillFlow, tmp_path: Path):
1146
+ """on_deliver as a LIST runs its tool hooks (not validation checks): a
1147
+ repo_apply in list form resolves $STEP_DIR and commits into the repo.
1148
+
1149
+ Regression: a list previously routed to StepValidator (check hook), which
1150
+ neither unwraps ``params`` nor resolves ``$STEP_DIR`` — so a repo-mutating
1151
+ tool placed there silently no-op'd.
1152
+ """
1153
+ import subprocess
1154
+ repo = tmp_path / "repo"
1155
+ repo.mkdir()
1156
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
1157
+ subprocess.run(["git", "config", "user.email", "t@t"], cwd=repo, check=True)
1158
+ subprocess.run(["git", "config", "user.name", "t"], cwd=repo, check=True)
1159
+
1160
+ sf._tool_loader = ToolLoader(
1161
+ Path(__file__).parent.parent / "src" / "skillflow" / "tools")
1162
+ sf._workspace = WorkspaceManager(
1163
+ str(tmp_path / "ws"), projects_base=str(tmp_path / "projects"),
1164
+ code_path_resolver=lambda pid: str(repo),
1165
+ )
1166
+
1167
+ node = StepNode(
1168
+ id="s1", step_type="agent", output_mode="content",
1169
+ output_fixed={"out": "main.py"},
1170
+ lifecycle={"on_deliver": [
1171
+ {"tool": "repo_apply", "params": {"source_dir": "$STEP_DIR"}},
1172
+ ]},
1173
+ transitions=[Transition(to=None)],
1174
+ )
1175
+ g = PipelineGraph(name="ondeliver_list", begin="s1", steps=[node])
1176
+ sf.register_graph(g)
1177
+
1178
+ rid = sf.create_run("ondeliver_list", {"project_id": "p1"})
1179
+ sf.start_run(rid)
1180
+ sf.advance_run(rid)
1181
+ token = sf.claim_next_step(rid)
1182
+ tmp = sf._workspace.get_step_tmp_dir("p1", "ondeliver_list", "s1")
1183
+ (tmp / "main.py").write_text("print('hi')\n")
1184
+ sf.confirm_step(token.token, StepResult(outputs={}, flags={}))
1185
+
1186
+ # Landed AND committed → the list ran the tool hook with $STEP_DIR resolved
1187
+ # (old code would have no-op'd via the check-hook path).
1188
+ assert (repo / "main.py").read_text() == "print('hi')\n"
1189
+ log = subprocess.run(["git", "log", "--oneline"], cwd=repo,
1190
+ capture_output=True, text=True).stdout
1191
+ assert "step: s1" in log, f"no apply commit:\n{log}"
1192
+
1193
+
1194
+ def test_on_deliver_list_order_and_on_failure(sf: SkillFlow, tmp_path: Path,
1195
+ monkeypatch):
1196
+ """on_deliver list = ordered tool-hook sequence honoring per-item
1197
+ on_failure; after_deliver list still routes to validation checks."""
1198
+ sf._tool_loader = ToolLoader(
1199
+ Path(__file__).parent.parent / "src" / "skillflow" / "tools")
1200
+ sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
1201
+ projects_base=str(tmp_path / "projects"))
1202
+ node = StepNode(
1203
+ id="s1", step_type="agent", output_mode="content",
1204
+ output_fixed={"out": "r.md"}, transitions=[Transition(to=None)],
1205
+ )
1206
+ g = PipelineGraph(name="seq", begin="s1", steps=[node])
1207
+ sf.register_graph(g)
1208
+ rid = sf.create_run("seq", {"project_id": "pid"})
1209
+ sf.start_run(rid)
1210
+ sf.advance_run(rid)
1211
+ token = sf.claim_next_step(rid).token # the ClaimToken inside the ClaimedStep
1212
+
1213
+ calls: list = []
1214
+
1215
+ def fake_tool_hook(tok, nd, hook_name, spec):
1216
+ calls.append(spec["tool"])
1217
+ if spec["tool"] == "boom":
1218
+ return {"passed": False, "error": "kaboom"}
1219
+ return {"passed": True, "files": ["x"]}
1220
+ monkeypatch.setattr(sf, "_execute_tool_hook", fake_tool_hook)
1221
+
1222
+ # 1) ordering + all-pass → sequence passes, surfaces files detail
1223
+ r = sf._execute_lifecycle_hook(token, node, "on_deliver",
1224
+ [{"tool": "repo_apply"}, {"tool": "repo_delete"}])
1225
+ assert calls == ["repo_apply", "repo_delete"]
1226
+ assert r["passed"] is True and r.get("files") == ["x"]
1227
+
1228
+ # 2) failing item with on_failure=warn → continue; sequence still passes
1229
+ calls.clear()
1230
+ r = sf._execute_lifecycle_hook(token, node, "on_deliver",
1231
+ [{"tool": "boom", "on_failure": "warn"}, {"tool": "repo_delete"}])
1232
+ assert calls == ["boom", "repo_delete"]
1233
+ assert r["passed"] is True
1234
+
1235
+ # 3) failing item with default on_failure=fail → stop + bubble on_failure
1236
+ calls.clear()
1237
+ r = sf._execute_lifecycle_hook(token, node, "on_deliver",
1238
+ [{"tool": "boom"}, {"tool": "repo_delete"}])
1239
+ assert calls == ["boom"]
1240
+ assert r["passed"] is False and r["on_failure"] == "fail"
1241
+
1242
+ # 4) after_deliver list is unchanged: routes to checks, not the tool
1243
+ # sequence, so the faked tool-hook path is never touched.
1244
+ calls.clear()
1245
+ sf._execute_lifecycle_hook(token, node, "after_deliver",
1246
+ [{"tool": "syntax_lint", "files": ["*.nomatch"]}])
1247
+ assert calls == []
1248
+
1249
+
1145
1250
  # ── Durable run trace ─────────────────────────────────────────────────
1146
1251
 
1147
1252
  def test_trace_append_and_get(sf: SkillFlow):
@@ -0,0 +1,184 @@
1
+ """Unit tests for recovery.py."""
2
+
3
+ import time
4
+
5
+ import pytest
6
+
7
+ from skillflow.core import SkillFlow
8
+ from skillflow.graph import PipelineGraph, StepNode, Transition
9
+
10
+
11
+ def _agent(id: str, transitions=None):
12
+ return StepNode(id=id, step_type="agent", transitions=transitions or [])
13
+
14
+
15
+ def test_recover_respects_tool_timeout_seconds(sf: SkillFlow):
16
+ """A TOOL step claimed longer than the flat threshold but WITHIN its
17
+ timeout_seconds is NOT reclaimed — a slow-but-alive tool (run_tests,
18
+ timeout 1200s) must not be relaunched concurrently with itself (the step-5
19
+ rampage). Only once the claim exceeds the tool timeout is it presumed dead.
20
+ Agent steps are intentionally NOT scoped by this (their reclaim timing
21
+ feeds a separate investigation)."""
22
+ graph = PipelineGraph(
23
+ name="test", begin="a",
24
+ steps=[StepNode(id="a", step_type="tool", tool_name="write",
25
+ timeout_seconds=1200, transitions=[])],
26
+ )
27
+ sf.register_graph(graph)
28
+ run_id = sf.create_run("test")
29
+ sf.start_run(run_id)
30
+ # Seed a claimed tool-step instance claimed 100s ago (already > flat 60s).
31
+ claimed_100s_ago = time.strftime(
32
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 100))
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, claimed_100s_ago))
40
+ sf._conn.commit()
41
+
42
+ # 100s > flat 60s, but < 1200s tool timeout → presumed ALIVE, NOT reclaimed.
43
+ assert sf.recover_stale_claims(stale_threshold_seconds=60) == []
44
+ st = sf._conn.execute(
45
+ "SELECT status FROM skillflow_steps WHERE run_id=? AND step_id='a' "
46
+ "ORDER BY id DESC LIMIT 1", (run_id,)).fetchone()
47
+ assert st["status"] == "claimed" # still held, not stolen
48
+
49
+ # Backdate beyond the 1200s tool timeout → now presumed dead → reclaimed.
50
+ claimed_1300s_ago = time.strftime(
51
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 1300))
52
+ with sf._lock:
53
+ sf._conn.execute(
54
+ "UPDATE skillflow_steps SET claimed_at=? WHERE run_id=? AND step_id='a'",
55
+ (claimed_1300s_ago, run_id))
56
+ sf._conn.commit()
57
+ assert run_id in sf.recover_stale_claims(stale_threshold_seconds=60)
58
+
59
+
60
+ def test_tool_reopen_caps_at_three_crashes(sf: SkillFlow):
61
+ """A tool step that crashes deterministically FAILS after 3 reopens instead
62
+ of being relaunched every tick (which rampaged until the host step-count
63
+ valve). The SF-20 stale-recovery cap doesn't cover the crash-reopen path."""
64
+ graph = PipelineGraph(
65
+ name="test", begin="a",
66
+ steps=[StepNode(id="a", step_type="agent", transitions=[])],
67
+ )
68
+ sf.register_graph(graph)
69
+ run_id = sf.create_run("test")
70
+ sf.start_run(run_id)
71
+ with sf._lock:
72
+ sf._conn.execute(
73
+ "INSERT INTO skillflow_steps (run_id, step_id, step_config_json, "
74
+ "inputs_json, max_retries, status, claimed_at, created_at, updated_at) "
75
+ "VALUES (?, 'a', '{}', '{}', 3, 'claimed', "
76
+ "'2026-01-01T00:00:00Z', datetime('now'), datetime('now'))",
77
+ (run_id,))
78
+ sf._conn.commit()
79
+
80
+ def _status():
81
+ return sf._conn.execute(
82
+ "SELECT status FROM skillflow_steps WHERE run_id=? AND step_id='a' "
83
+ "ORDER BY id DESC LIMIT 1", (run_id,)).fetchone()["status"]
84
+
85
+ # Crashes 1 and 2 reopen to pending; re-claim to simulate the next attempt.
86
+ for _ in range(2):
87
+ sf._reopen_tool_step_in_tx(run_id, "a")
88
+ assert _status() == "pending"
89
+ with sf._lock:
90
+ sf._conn.execute(
91
+ "UPDATE skillflow_steps SET status='claimed' "
92
+ "WHERE run_id=? AND step_id='a'", (run_id,))
93
+ sf._conn.commit()
94
+
95
+ # Third crash → failed, not reopened → no more relaunches.
96
+ sf._reopen_tool_step_in_tx(run_id, "a")
97
+ assert _status() == "failed"
98
+
99
+
100
+ def _trans(to: str):
101
+ return Transition(to=to)
102
+
103
+
104
+ def test_recover_stale_claims_resets_claimed(sf: SkillFlow):
105
+ graph = PipelineGraph(
106
+ name="test", begin="a",
107
+ steps=[_agent("a", [_trans("b")]), _agent("b", [])],
108
+ )
109
+ sf.register_graph(graph)
110
+ run_id = sf.create_run("test")
111
+ sf.start_run(run_id)
112
+ sf.advance_run(run_id)
113
+ sf.claim_next_step(run_id)
114
+
115
+ # Recover with negative threshold (everything is stale)
116
+ recovered = sf.recover_stale_claims(stale_threshold_seconds=-1)
117
+ assert run_id in recovered
118
+
119
+ # Step should be re-claimable
120
+ sf.advance_run(run_id)
121
+ claimed = sf.claim_next_step(run_id)
122
+ assert claimed is not None
123
+ assert claimed.step_id == "a"
124
+
125
+
126
+ def test_recover_stale_claims_fresh_not_affected(sf: SkillFlow):
127
+ graph = PipelineGraph(
128
+ name="test", begin="a",
129
+ steps=[_agent("a", [])],
130
+ )
131
+ sf.register_graph(graph)
132
+ run_id = sf.create_run("test")
133
+ sf.start_run(run_id)
134
+ sf.advance_run(run_id)
135
+ sf.claim_next_step(run_id)
136
+
137
+ # Default threshold — just-claimed step is fresh
138
+ recovered = sf.recover_stale_claims(stale_threshold_seconds=300)
139
+ assert len(recovered) == 0
140
+
141
+
142
+ def test_recover_stale_claims_no_stale_steps(sf: SkillFlow):
143
+ graph = PipelineGraph(
144
+ name="test", begin="a",
145
+ steps=[_agent("a", [])],
146
+ )
147
+ sf.register_graph(graph)
148
+ sf.create_run("test")
149
+ recovered = sf.recover_stale_claims()
150
+ assert recovered == []
151
+
152
+
153
+ def test_recover_stale_keeps_current_node(sf: SkillFlow):
154
+ graph = PipelineGraph(
155
+ name="test", begin="a",
156
+ steps=[_agent("a", [_trans("b")]), _agent("b", [])],
157
+ )
158
+ sf.register_graph(graph)
159
+ run_id = sf.create_run("test")
160
+ sf.start_run(run_id)
161
+ sf.advance_run(run_id)
162
+ sf.claim_next_step(run_id)
163
+
164
+ sf.recover_stale_claims(stale_threshold_seconds=-1)
165
+ run = sf.get_run(run_id)
166
+ # current_node is kept so advance_run re-claims the crashed step
167
+ assert run["current_node"] == "a"
168
+
169
+
170
+ def test_recover_stale_claims_method_on_instance(sf_tmp: SkillFlow):
171
+ """Test the SkillFlow.recover_stale_claims() method (replaces standalone function)."""
172
+
173
+ graph = PipelineGraph(
174
+ name="test", begin="a",
175
+ steps=[_agent("a", [])],
176
+ )
177
+ sf_tmp.register_graph(graph)
178
+ run_id = sf_tmp.create_run("test")
179
+ sf_tmp.start_run(run_id)
180
+ sf_tmp.advance_run(run_id)
181
+ sf_tmp.claim_next_step(run_id)
182
+
183
+ recovered = sf_tmp.recover_stale_claims(stale_threshold_seconds=-1)
184
+ assert len(recovered) >= 0
@@ -1,99 +0,0 @@
1
- """Unit tests for recovery.py."""
2
-
3
- import time
4
-
5
- import pytest
6
-
7
- from skillflow.core import SkillFlow
8
- from skillflow.graph import PipelineGraph, StepNode, Transition
9
-
10
-
11
- def _agent(id: str, transitions=None):
12
- return StepNode(id=id, step_type="agent", transitions=transitions or [])
13
-
14
-
15
- def _trans(to: str):
16
- return Transition(to=to)
17
-
18
-
19
- def test_recover_stale_claims_resets_claimed(sf: SkillFlow):
20
- graph = PipelineGraph(
21
- name="test", begin="a",
22
- steps=[_agent("a", [_trans("b")]), _agent("b", [])],
23
- )
24
- sf.register_graph(graph)
25
- run_id = sf.create_run("test")
26
- sf.start_run(run_id)
27
- sf.advance_run(run_id)
28
- sf.claim_next_step(run_id)
29
-
30
- # Recover with negative threshold (everything is stale)
31
- recovered = sf.recover_stale_claims(stale_threshold_seconds=-1)
32
- assert run_id in recovered
33
-
34
- # Step should be re-claimable
35
- sf.advance_run(run_id)
36
- claimed = sf.claim_next_step(run_id)
37
- assert claimed is not None
38
- assert claimed.step_id == "a"
39
-
40
-
41
- def test_recover_stale_claims_fresh_not_affected(sf: SkillFlow):
42
- graph = PipelineGraph(
43
- name="test", begin="a",
44
- steps=[_agent("a", [])],
45
- )
46
- sf.register_graph(graph)
47
- run_id = sf.create_run("test")
48
- sf.start_run(run_id)
49
- sf.advance_run(run_id)
50
- sf.claim_next_step(run_id)
51
-
52
- # Default threshold — just-claimed step is fresh
53
- recovered = sf.recover_stale_claims(stale_threshold_seconds=300)
54
- assert len(recovered) == 0
55
-
56
-
57
- def test_recover_stale_claims_no_stale_steps(sf: SkillFlow):
58
- graph = PipelineGraph(
59
- name="test", begin="a",
60
- steps=[_agent("a", [])],
61
- )
62
- sf.register_graph(graph)
63
- sf.create_run("test")
64
- recovered = sf.recover_stale_claims()
65
- assert recovered == []
66
-
67
-
68
- def test_recover_stale_keeps_current_node(sf: SkillFlow):
69
- graph = PipelineGraph(
70
- name="test", begin="a",
71
- steps=[_agent("a", [_trans("b")]), _agent("b", [])],
72
- )
73
- sf.register_graph(graph)
74
- run_id = sf.create_run("test")
75
- sf.start_run(run_id)
76
- sf.advance_run(run_id)
77
- sf.claim_next_step(run_id)
78
-
79
- sf.recover_stale_claims(stale_threshold_seconds=-1)
80
- run = sf.get_run(run_id)
81
- # current_node is kept so advance_run re-claims the crashed step
82
- assert run["current_node"] == "a"
83
-
84
-
85
- def test_recover_stale_claims_method_on_instance(sf_tmp: SkillFlow):
86
- """Test the SkillFlow.recover_stale_claims() method (replaces standalone function)."""
87
-
88
- graph = PipelineGraph(
89
- name="test", begin="a",
90
- steps=[_agent("a", [])],
91
- )
92
- sf_tmp.register_graph(graph)
93
- run_id = sf_tmp.create_run("test")
94
- sf_tmp.start_run(run_id)
95
- sf_tmp.advance_run(run_id)
96
- sf_tmp.claim_next_step(run_id)
97
-
98
- recovered = sf_tmp.recover_stale_claims(stale_threshold_seconds=-1)
99
- assert len(recovered) >= 0
File without changes
File without changes
File without changes