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.
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/PKG-INFO +1 -1
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/pyproject.toml +1 -1
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/core.py +104 -28
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_apply/impl.py +23 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/write_tools.py +28 -16
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/PKG-INFO +1 -1
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_tool_lock_contention.py +73 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/LICENSE +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/README.md +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/setup.cfg +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/__init__.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/agent_registry.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/context.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/convert_cli.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/exceptions.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/graph.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/notifications.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/outbox.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/__init__.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/cli.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/converter.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_runner/runner.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/read_tools.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/recovery.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/run_cli.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/schema.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/step_validation.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tool_loader.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/__init__.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/dir_tree/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/draft_commit/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/file_exists/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/file_exists/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/json_schema/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/json_schema/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/lint/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/lint/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/list_tree/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/list_tree/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/notify/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/notify/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/pytest/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/pytest/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/read_file/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/read_file/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_validate/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/write/impl.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/tools/write/tool.yaml +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/validation.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/workspace.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/entry_points.txt +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/requires.txt +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow_py.egg-info/top_level.txt +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_context.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_core.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_exceptions.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_existing_repo_apply.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_git_sync_pre.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_graph.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_integration.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_integration_configs.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_migration_regression.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_notifications.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_outbox.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_read_tools_exec.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_read_tools_label.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_recovery.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_regression.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_step_validation.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_tool_loader.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_tools.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_validation.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_workspace.py +0 -0
- {skillflow_py-1.3.0 → skillflow_py-1.3.2}/tests/test_write_tools.py +0 -0
|
@@ -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
|
-
#
|
|
936
|
-
#
|
|
937
|
-
#
|
|
938
|
-
#
|
|
939
|
-
#
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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 produced — staging
|
|
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
|
|
2227
|
-
#
|
|
2228
|
-
#
|
|
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
|
-
#
|
|
2232
|
-
#
|
|
2233
|
-
#
|
|
2234
|
-
#
|
|
2235
|
-
#
|
|
2236
|
-
#
|
|
2237
|
-
#
|
|
2238
|
-
#
|
|
2239
|
-
#
|
|
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
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/converter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.3.0 → skillflow_py-1.3.2}/src/skillflow/plugins/skill_converter/skill_converter.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|