skillflow-py 1.3.0__tar.gz → 1.3.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 (93) hide show
  1. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/PKG-INFO +1 -1
  2. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/pyproject.toml +1 -1
  3. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/core.py +104 -28
  4. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_apply/impl.py +23 -0
  5. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/write_tools.py +28 -16
  6. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/PKG-INFO +1 -1
  7. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_tool_lock_contention.py +73 -0
  8. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/LICENSE +0 -0
  9. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/README.md +0 -0
  10. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/setup.cfg +0 -0
  11. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/__init__.py +0 -0
  12. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/agent_registry.py +0 -0
  13. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/context.py +0 -0
  14. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/convert_cli.py +0 -0
  15. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/exceptions.py +0 -0
  16. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/graph.py +0 -0
  17. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/notifications.py +0 -0
  18. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/outbox.py +0 -0
  19. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/__init__.py +0 -0
  20. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/cli.py +0 -0
  21. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  22. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  23. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  24. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  25. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  26. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  27. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  28. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  29. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  30. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  31. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  32. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  33. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  34. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/read_tools.py +0 -0
  35. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/recovery.py +0 -0
  36. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/run_cli.py +0 -0
  37. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/schema.py +0 -0
  38. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/step_validation.py +0 -0
  39. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tool_loader.py +0 -0
  40. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/__init__.py +0 -0
  41. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/dir_tree/impl.py +0 -0
  42. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  43. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/draft_commit/impl.py +0 -0
  44. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  45. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/file_exists/impl.py +0 -0
  46. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  47. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  48. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  49. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/json_schema/impl.py +0 -0
  50. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  51. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/lint/impl.py +0 -0
  52. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/lint/tool.yaml +0 -0
  53. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/list_tree/impl.py +0 -0
  54. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  55. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/notify/impl.py +0 -0
  56. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/notify/tool.yaml +0 -0
  57. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/pytest/impl.py +0 -0
  58. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/pytest/tool.yaml +0 -0
  59. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/read_file/impl.py +0 -0
  60. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/read_file/tool.yaml +0 -0
  61. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
  62. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_validate/impl.py +0 -0
  63. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  64. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/write/impl.py +0 -0
  65. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/write/tool.yaml +0 -0
  66. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/validation.py +0 -0
  67. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/workspace.py +0 -0
  68. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
  69. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  70. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  71. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/requires.txt +0 -0
  72. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/top_level.txt +0 -0
  73. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_context.py +0 -0
  74. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_core.py +0 -0
  75. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_exceptions.py +0 -0
  76. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_existing_repo_apply.py +0 -0
  77. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_git_sync_pre.py +0 -0
  78. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_graph.py +0 -0
  79. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_integration.py +0 -0
  80. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_integration_configs.py +0 -0
  81. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_migration_regression.py +0 -0
  82. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_notifications.py +0 -0
  83. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_outbox.py +0 -0
  84. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_read_tools_exec.py +0 -0
  85. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_read_tools_label.py +0 -0
  86. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_recovery.py +0 -0
  87. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_regression.py +0 -0
  88. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_step_validation.py +0 -0
  89. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_tool_loader.py +0 -0
  90. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_tools.py +0 -0
  91. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_validation.py +0 -0
  92. {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_workspace.py +0 -0
  93. {skillflow_py-1.3.0 → skillflow_py-1.3.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.3.0
3
+ Version: 1.3.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.3.0"
7
+ version = "1.3.2"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -932,14 +932,14 @@ class SkillFlow:
932
932
  tmp_dir = self._workspace.get_step_tmp_dir(
933
933
  run["project_id"], run["graph_name"], node.id
934
934
  )
935
- # Reset staging at the start of each attempt so create/edit/write
936
- # rebuild from a clean baseline (the repo). Without this, a partial
937
- # result from a failed attempt pollutes the next one e.g. a
938
- # re-issued edit(old→new) would fail because 'old' was already
939
- # replaced last attempt. Keeps step execution idempotent on retry.
940
- import shutil
941
- if tmp_dir.exists():
942
- shutil.rmtree(str(tmp_dir))
935
+ # Staging PERSISTS across retries (do not wipe). A retry inherits
936
+ # the prior attempt's prompt (KV-cache reuse), so the agent issues
937
+ # follow-up edits against the state it already producedstaging
938
+ # must match that accumulated state. Wiping would both lose files
939
+ # a prior attempt created and break follow-up edit() calls
940
+ # (old_str reverted to the repo baseline → "not found"). A
941
+ # successful step consumes tmp via promotion (tmp→step_dir rename),
942
+ # so the next step still starts clean without an explicit wipe.
943
943
  tmp_dir.mkdir(parents=True, exist_ok=True)
944
944
  inputs_with_tools["_output_dir"] = str(tmp_dir)
945
945
  if node.output_fixed:
@@ -2216,6 +2216,74 @@ class SkillFlow:
2216
2216
  )
2217
2217
  return None
2218
2218
 
2219
+ def _claim_tool_step_in_tx(self, run_id: str, step_id: str, node) -> int | None:
2220
+ """Atomically claim a tool step (pending→claimed) for inline execution.
2221
+
2222
+ Returns the claimed step-instance id, or None if the step is not
2223
+ claimable — i.e. a concurrent advance_run() already claimed it. Mirrors
2224
+ claim_next_step's CAS so the in-flight guard + runaway valve (which key
2225
+ on the 'claimed' status/trace) see inline tool steps too, making
2226
+ execution idempotent under concurrent drivers sharing this DB.
2227
+ """
2228
+ with self._tx() as conn:
2229
+ row = conn.execute(
2230
+ "SELECT version FROM skillflow_steps "
2231
+ "WHERE run_id = ? AND step_id = ? AND status = 'pending'",
2232
+ (run_id, step_id),
2233
+ ).fetchone()
2234
+ if not row:
2235
+ existing = conn.execute(
2236
+ "SELECT status FROM skillflow_steps "
2237
+ "WHERE run_id = ? AND step_id = ? ORDER BY id DESC LIMIT 1",
2238
+ (run_id, step_id),
2239
+ ).fetchone()
2240
+ if existing and existing["status"] == "claimed":
2241
+ return None # a concurrent driver owns it
2242
+ # First run with no row, or cyclic re-entry (prev completed/
2243
+ # failed): open a fresh pending instance to claim.
2244
+ conn.execute(
2245
+ "INSERT INTO skillflow_steps (run_id, step_id, "
2246
+ "step_config_json, max_retries, status, created_at, updated_at) "
2247
+ "VALUES (?, ?, ?, ?, 'pending', datetime('now'), datetime('now'))",
2248
+ (run_id, step_id, self._serialize(node.config), node.max_retries),
2249
+ )
2250
+ row = conn.execute(
2251
+ "SELECT version FROM skillflow_steps "
2252
+ "WHERE run_id = ? AND step_id = ? AND status = 'pending'",
2253
+ (run_id, step_id),
2254
+ ).fetchone()
2255
+ if not row:
2256
+ return None
2257
+ ver = row["version"]
2258
+ claimed_at_str = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time()))
2259
+ cur = conn.execute(
2260
+ "UPDATE skillflow_steps SET status = 'claimed', version = version + 1, "
2261
+ "claimed_at = ?, claimed_by = ?, updated_at = datetime('now') "
2262
+ "WHERE run_id = ? AND step_id = ? AND version = ? AND status = 'pending'",
2263
+ (claimed_at_str, "tool-inline", run_id, step_id, ver),
2264
+ )
2265
+ if cur.rowcount == 0:
2266
+ return None # lost the race
2267
+ inst = conn.execute(
2268
+ "SELECT id FROM skillflow_steps "
2269
+ "WHERE run_id = ? AND step_id = ? AND status = 'claimed' "
2270
+ "ORDER BY id DESC LIMIT 1",
2271
+ (run_id, step_id),
2272
+ ).fetchone()
2273
+ return inst["id"] if inst else None
2274
+
2275
+ 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.
2278
+ """
2279
+ with self._tx() as conn:
2280
+ conn.execute(
2281
+ "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),
2285
+ )
2286
+
2219
2287
  def advance_run(self, run_id: str) -> str | None:
2220
2288
  # Recover stale claims before any traversal
2221
2289
  self.recover_stale_claims(self._stale_threshold)
@@ -2223,23 +2291,19 @@ class SkillFlow:
2223
2291
  resolver = self._get_resolver_for_run(run_id)
2224
2292
 
2225
2293
  # ── Tool fast-path: execute OUTSIDE any write transaction ──
2226
- # Long-running tools (e.g. run_tests at 10-15 s) must not hold
2227
- # the SQLite write lock — that blocks agent trace writes and
2228
- # other scheduler ticks, causing SQLITE_BUSY → crashed agents →
2229
- # stale claims → infinite loops.
2294
+ # Long-running tools (e.g. run_tests) must not hold the SQLite write
2295
+ # lock — that blocks agent trace writes and other scheduler ticks,
2296
+ # causing SQLITE_BUSY → crashed agents → stale claims → infinite loops.
2230
2297
  #
2231
- # IDEMPOTENCY NOTE: this inline tool execution is NOT claim-guarded —
2232
- # two overlapping advance_run() calls would both run the tool. That is
2233
- # currently safe ONLY because advance_run is synchronous and the host
2234
- # scheduler drives it on a single event loop in a single process (the
2235
- # P0-1 advisory lock), so calls for one run can never overlap. The tool
2236
- # runs synchronously here and blocks that loop for its whole duration.
2237
- # If a host ever makes tool execution non-blocking (await to_thread /
2238
- # run_in_executor) so the loop stays responsive during a slow tool,
2239
- # advance_run gains a yield point mid-tool and concurrent ticks CAN then
2240
- # overlap → this path must become claim-guarded (CAS pending→claimed
2241
- # before executing; only the winner runs the tool). Land the two
2242
- # changes together, never one without the other.
2298
+ # CLAIM-GUARDED (1.3.2): a CAS pending→claimed precedes execution, so
2299
+ # only one advance_run() call runs the tool. This is REQUIRED for
2300
+ # correctness when more than one driver advances the same run — e.g. a
2301
+ # host CLI + a Docker container sharing this DB, or the wake-on-confirm
2302
+ # and interval jobs overlapping. Without it, an unclaimed slow tool gets
2303
+ # re-launched by every tick dozens of concurrent run_tests pile up and
2304
+ # mutually starve (the step-5 rampage). Losers return None and back off;
2305
+ # a crashed tool reopens to pending; a dead driver's claim is reclaimed
2306
+ # via recover_stale_claims once the node's timeout_seconds elapses.
2243
2307
  run_row = self._conn.execute(
2244
2308
  "SELECT * FROM skillflow_runs WHERE id = ?", (run_id,)
2245
2309
  ).fetchone()
@@ -2250,10 +2314,22 @@ class SkillFlow:
2250
2314
  tool_node = resolver.get_node(current)
2251
2315
  if tool_node and not self._should_delegate_tool(
2252
2316
  tool_node.tool_name):
2253
- # Execute tool WITHOUT holding any lock/transaction
2254
- tool_result = self._execute_tool_inline(
2255
- tool_node, run_id=run_id,
2256
- graph_name=run_row["graph_name"])
2317
+ inst_id = self._claim_tool_step_in_tx(
2318
+ run_id, current, tool_node)
2319
+ if inst_id is None:
2320
+ return None # another driver owns this tool step
2321
+ self.trace(run_id, "step", "claimed",
2322
+ {"tool": tool_node.tool_name, "inline": True},
2323
+ step_id=current, step_instance_id=inst_id)
2324
+ try:
2325
+ # Execute tool WITHOUT holding any lock/transaction
2326
+ tool_result = self._execute_tool_inline(
2327
+ tool_node, run_id=run_id,
2328
+ graph_name=run_row["graph_name"])
2329
+ except Exception:
2330
+ # Don't leave a crashed tool wedged in 'claimed'.
2331
+ self._reopen_tool_step_in_tx(run_id, current)
2332
+ raise
2257
2333
  return self._complete_tool_step(
2258
2334
  run_id, current, tool_result, run_row, resolver)
2259
2335
 
@@ -2,9 +2,18 @@
2
2
 
3
3
  import shutil
4
4
  import subprocess
5
+ import sys
5
6
  from pathlib import Path
6
7
 
7
8
 
9
+ def _probe(msg: str) -> None:
10
+ """Diagnostic log for the step-dir-contains-whole-repo investigation.
11
+
12
+ Prints to stderr with a greppable marker so it shows in `docker logs`.
13
+ """
14
+ print(f"[repo_apply.probe] {msg}", file=sys.stderr, flush=True)
15
+
16
+
8
17
  def repo_apply(source_dir: str, *, workspace_root: str = "",
9
18
  project_root: str = "",
10
19
  step_id: str = "", project_id: str = "",
@@ -15,6 +24,12 @@ def repo_apply(source_dir: str, *, workspace_root: str = "",
15
24
  src = src.resolve()
16
25
  dst = Path(project_root).resolve()
17
26
 
27
+ # PROBE: what is repo_apply actually reading from, and is it the repo itself?
28
+ _probe(f"step={step_id} project={project_id} task={task_name!r} "
29
+ f"source_dir_param={source_dir!r} resolved_src={src} "
30
+ f"project_root={dst} workspace_root={workspace_root!r} "
31
+ f"src==dst? {src == dst}")
32
+
18
33
  if not src.exists():
19
34
  return {"applied": False, "files": [],
20
35
  "error": f"Source dir not found: {source_dir}"}
@@ -33,6 +48,14 @@ def repo_apply(source_dir: str, *, workspace_root: str = "",
33
48
  shutil.copy2(f, target)
34
49
  applied_files.append(str(rel))
35
50
 
51
+ # PROBE: how many files did we just apply? A handful = normal surgical edit;
52
+ # dozens/hundreds = the leak. Dump a sample so we can see WHAT leaked.
53
+ if len(applied_files) > 15:
54
+ _probe(f"LARGE APPLY step={step_id}: {len(applied_files)} files from "
55
+ f"{src} — first 15: {applied_files[:15]}")
56
+ else:
57
+ _probe(f"step={step_id}: applied {len(applied_files)} file(s): {applied_files}")
58
+
36
59
  if not applied_files:
37
60
  return {"applied": False, "files": [],
38
61
  "error": "No files to apply (empty or all filtered)"}
@@ -312,6 +312,26 @@ def execute_append(slot: str, fixed: dict, params: dict,
312
312
  return {"written": base_name}
313
313
 
314
314
 
315
+ def _unique_replace(content: str, old_str: str, new_str: str, *,
316
+ tool: str, name: str) -> "tuple[str | None, dict | None]":
317
+ """Replace the single exact occurrence of ``old_str`` in ``content``.
318
+
319
+ Shared by the slot-mode (``edit_{slot}``) and generic (``edit``) executors so
320
+ the uniqueness-and-replace rule — the safety-critical heart of a surgical
321
+ edit — lives in exactly one place. Returns ``(updated_content, None)`` on
322
+ success, or ``(None, error_dict)`` when ``old_str`` is absent or not unique.
323
+ ``tool`` and ``name`` only shape the error message (caller's tool label and
324
+ the file path).
325
+ """
326
+ occurrences = content.count(old_str)
327
+ if occurrences == 0:
328
+ return None, {"error": f"{tool}: 'old_str' not found in '{name}'"}
329
+ if occurrences > 1:
330
+ return None, {"error": (f"{tool}: 'old_str' matches {occurrences} times in "
331
+ f"'{name}' — include more surrounding context to make it unique")}
332
+ return content.replace(old_str, new_str, 1), None
333
+
334
+
315
335
  def execute_edit(slot: str, fixed: dict, params: dict,
316
336
  output_dir: str, source_dir: str = "") -> dict:
317
337
  """Execute an edit_{slot} call: surgical str-replace on the EXISTING file.
@@ -339,14 +359,10 @@ def execute_edit(slot: str, fixed: dict, params: dict,
339
359
  return {"error": f"edit_{slot}: cannot edit '{base_name}' — file does not exist"}
340
360
 
341
361
  content = src.read_text(encoding="utf-8")
342
- occurrences = content.count(old_str)
343
- if occurrences == 0:
344
- return {"error": f"edit_{slot}: 'old_str' not found in '{base_name}'"}
345
- if occurrences > 1:
346
- return {"error": (f"edit_{slot}: 'old_str' matches {occurrences} times in "
347
- f"'{base_name}' — include more surrounding context to make it unique")}
348
-
349
- updated = content.replace(old_str, new_str, 1)
362
+ updated, err = _unique_replace(content, old_str, new_str,
363
+ tool=f"edit_{slot}", name=base_name)
364
+ if err:
365
+ return err
350
366
  out = Path(output_dir) / base_name
351
367
  out.parent.mkdir(parents=True, exist_ok=True)
352
368
  out.write_text(updated, encoding="utf-8")
@@ -437,14 +453,10 @@ def execute_generic_edit(params: dict, output_dir: str,
437
453
  f"(use 'create' for a new file).")}
438
454
 
439
455
  content = src.read_text(encoding="utf-8")
440
- occurrences = content.count(old_str)
441
- if occurrences == 0:
442
- return {"error": f"edit: 'old_str' not found in '{rel}'"}
443
- if occurrences > 1:
444
- return {"error": (f"edit: 'old_str' matches {occurrences} times in '{rel}' "
445
- f"— include more surrounding context to make it unique")}
446
-
447
- updated = content.replace(old_str, new_str, 1)
456
+ updated, err = _unique_replace(content, old_str, new_str,
457
+ tool="edit", name=rel)
458
+ if err:
459
+ return err
448
460
  staged.parent.mkdir(parents=True, exist_ok=True)
449
461
  staged.write_text(updated, encoding="utf-8")
450
462
  return {"edited": rel}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -110,3 +110,76 @@ def test_inline_tool_does_not_hold_serialisation_lock(tmp_path):
110
110
  "inline tool executed while advance_run held the serialisation lock "
111
111
  f"(self._lock) — blocks concurrent ticks: observed={observed}"
112
112
  )
113
+
114
+
115
+ def test_inline_tool_is_claim_guarded_against_concurrent_drivers(tmp_path):
116
+ """Two advance_run() calls overlapping on one tool step run it ONCE.
117
+
118
+ Root cause of the step-5 rampage (2026-06-28): the inline tool fast-path was
119
+ not claim-guarded, so when two drivers shared the DB (a host CLI + a Docker
120
+ container), each tick re-launched the still-running run_tests → dozens of
121
+ concurrent pytest piling up. The CAS pending->claimed makes only the winner
122
+ execute; the loser backs off (returns None).
123
+ """
124
+ db_path = str(tmp_path / "claimguard.db")
125
+ runs: list[int] = []
126
+ in_tool = threading.Event()
127
+ release = threading.Event()
128
+
129
+ def slow_probe(**kwargs):
130
+ runs.append(1)
131
+ in_tool.set() # we hold the claim now
132
+ release.wait(timeout=5) # ...while a second driver also advances
133
+ return {"passed": True}
134
+
135
+ tools = MockToolLoader()
136
+ tools.register("slow_probe", slow_probe)
137
+ sf = SkillFlow(db_path, tool_loader=tools)
138
+ sf.register_agent_config("noop_agent")
139
+
140
+ graph = PipelineGraph(
141
+ name="claimguard",
142
+ begin="gen",
143
+ steps=[
144
+ StepNode(id="gen", step_type="agent", agent_config="noop_agent",
145
+ transitions=[Transition(to="probe")]),
146
+ StepNode(id="probe", step_type="tool", tool_name="slow_probe",
147
+ transitions=[Transition(to="done")]),
148
+ StepNode(id="done", step_type="agent", agent_config="noop_agent",
149
+ transitions=[]),
150
+ ],
151
+ end_conditions=EndConditions(
152
+ combinator="or",
153
+ conditions=[EndCondition(type="node_reached", node="done",
154
+ result="completed")],
155
+ ),
156
+ )
157
+ sf.register_graph(graph)
158
+ run_id = sf.create_run("claimguard")
159
+ sf.start_run(run_id)
160
+
161
+ sf.advance_run(run_id) # resolve -> "gen"
162
+ claimed = sf.claim_next_step(run_id)
163
+ sf.confirm_step(claimed.token, StepResult(outputs={}, flags={}))
164
+
165
+ # Resolve gen -> probe. The tool is DEFERRED (current_node=probe) and only
166
+ # executed by the fast-path on a SUBSEQUENT advance_run — stop the moment we
167
+ # reach that deferred state, before the tool runs.
168
+ for _ in range(5):
169
+ sf.advance_run(run_id)
170
+ if sf.get_run(run_id)["current_node"] == "probe":
171
+ break
172
+ assert sf.get_run(run_id)["current_node"] == "probe"
173
+ assert runs == [], "tool ran during deferral — expected execution only via fast-path"
174
+
175
+ # Now two drivers race the fast-path. A claims + runs it (holding the claim
176
+ # on the release event); B advances concurrently and must back off.
177
+ a = threading.Thread(target=lambda: sf.advance_run(run_id))
178
+ a.start()
179
+ assert in_tool.wait(timeout=5), "tool never started"
180
+
181
+ sf.advance_run(run_id) # driver B: must see the claim and back off
182
+ release.set()
183
+ a.join(timeout=5)
184
+
185
+ assert runs == [1], f"tool executed {len(runs)}x under concurrency, expected 1"
File without changes
File without changes
File without changes