subtask-mcp 0.3.1__tar.gz → 0.4.4__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.
- {subtask_mcp-0.3.1/subtask_mcp.egg-info → subtask_mcp-0.4.4}/PKG-INFO +15 -3
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/README.md +14 -2
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/__init__.py +1 -1
- subtask_mcp-0.4.4/subtask/ast_merge.py +124 -0
- subtask_mcp-0.4.4/subtask/bisect_tool.py +141 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/cli.py +3 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/core.py +148 -5
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/fastserver.py +11 -1
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/groups.py +69 -3
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/commands.py +30 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/integrate.py +51 -3
- subtask_mcp-0.4.4/subtask/invariants.py +162 -0
- subtask_mcp-0.4.4/subtask/oracle.py +80 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/server.py +69 -12
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/skill/SKILL.md +44 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/tools.json +98 -8
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/worker.py +47 -1
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4/subtask_mcp.egg-info}/PKG-INFO +15 -3
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/SOURCES.txt +9 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_board.py +39 -0
- subtask_mcp-0.4.4/tests/test_falsify_040.py +156 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_falsify_tappe.py +8 -3
- subtask_mcp-0.4.4/tests/test_fanout_consensus.py +149 -0
- subtask_mcp-0.4.4/tests/test_guidance.py +101 -0
- subtask_mcp-0.4.4/tests/test_invariants_ast.py +215 -0
- subtask_mcp-0.4.4/tests/test_oracle_bisect.py +121 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_protocol.py +1 -1
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_schema.py +1 -1
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_subtask.py +1 -1
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/LICENSE +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/pyproject.toml +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/setup.cfg +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/admission.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/capacity.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/config.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/context.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/doctor.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/__init__.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/base.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/claude_api.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/codex.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/demo.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/dsh.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/kimi.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/opencode.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/registry.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/events.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/evidence.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/gc.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/__init__.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/claude_plugin.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/clients.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/doctor.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/fileops.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/installer.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/ownership.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/tomledit.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/jobs.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/ledger.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/map_reduce.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/memory.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/narrate.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/policy.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/procid.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/progress.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/redact.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/roi.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/router.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/runners.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/schemacheck.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/stephook.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/telemetry.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/verifier.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/watchdog.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/dependency_links.txt +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/entry_points.txt +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/requires.txt +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/top_level.txt +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_concurrency.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_context_dag.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_engines.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_falsify.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_independent_client.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_install.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_live.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_map_engrams.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_plan_gaps.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_routing_results.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_security.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_steer_wait.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_stephook.py +0 -0
- {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_worktree_verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subtask-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/tedin7/subtask
|
|
@@ -111,9 +111,11 @@ redaction is a second layer, not a guarantee. See [SECURITY.md](SECURITY.md).
|
|
|
111
111
|
|
|
112
112
|
| tool | what it does |
|
|
113
113
|
|---|---|
|
|
114
|
-
| `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify, isolation, max_retries, adversarial, context_refs, auto_repomap, tier)` | start one job (see below) |
|
|
115
|
-
| `start_many(tasks)` | start several; partial success is listed per item. Items with `id` and `depends_on` form a group (`grp-…`) run as a DAG without extra turns |
|
|
114
|
+
| `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify, isolation, max_retries, adversarial, context_refs, auto_repomap, tier, invariants, allowed_paths, symbol_scope, oracle_cmd)` | start one job (see below) |
|
|
115
|
+
| `start_many(tasks)` | start several; partial success is listed per item. Items with `id` and `depends_on` form a group (`grp-…`) run as a DAG without extra turns; `foreach=<parent id>` makes one copy per item of the parent's `.subtask/fanout.json` (at most 8), and dependents wait for every copy; `after="any"` starts when one parent succeeded |
|
|
116
116
|
| `integrate(ids, action="apply", auto_resolve=False)` | worktree jobs back: `diff`, `apply` (all or nothing, uncommitted), `discard`; `auto_resolve` starts a reconciliation job on conflict |
|
|
117
|
+
| `consensus(prompt, engines, verify_cmd, isolation="worktree")` | 2 to 4 engines solve it blind in their own worktrees, then one job compares their diffs as anonymous patches and writes one solution, checked by `verify_cmd` and the invariants; returns a group id |
|
|
118
|
+
| `bisect(good_ref, bad_ref, test_cmd, parallelism=4, auto_fix=False)` | first commit that makes `test_cmd` fail, testing up to `parallelism` (max 8) commits per round in temporary worktrees; returns the commit, author and diff; `auto_fix` starts a verified fix job |
|
|
117
119
|
| `map_reduce(glob_pattern, prompt_template, batch_size=5, concurrency=3, verify_cmd, isolation="worktree")` | one job per batch of files (`{{files}}` in the template), at most `concurrency` at a time; returns a group id |
|
|
118
120
|
| `wait(ids, timeout=300, mode="all", since=None, stall_timeout=None)` | block until they end (`any`: the first; `first_success`: the first done and verified, the others are stopped); returns early on a stalled job; progress notifications while waiting; `since` cursor for clients without progress |
|
|
119
121
|
| `steer(id, action, instruction=None)` | `hint` (queued, sent as a reply when the attempt ends), `abort`, `retry` (fresh job with the instruction) |
|
|
@@ -133,6 +135,16 @@ Options of `start`:
|
|
|
133
135
|
Builder's worktree and run with `verify`.
|
|
134
136
|
- `context_refs`: `file:PATH[:A-B]`, `git:diff[:REF]`, `subtask:ID:summary|diff`, resolved before start.
|
|
135
137
|
`auto_repomap=True` adds tracked files with their top-level symbols (cached per HEAD).
|
|
138
|
+
- `invariants` (default `["no_test_deletion", "no_suppression"]`, `[]` turns them off): before `verify` counts,
|
|
139
|
+
test functions may not decrease, no new skip/xfail markers, no new `# type: ignore`, `# noqa` or
|
|
140
|
+
`# pragma: no cover`. A violation is a failed check with the reason, so a retry sees it.
|
|
141
|
+
- `allowed_paths`: files changed outside these paths are put back and the revert is committed.
|
|
142
|
+
- `symbol_scope=["path.py::name", ...]`: only those top-level functions or classes may change in those
|
|
143
|
+
files. `integrate` then merges several jobs' edits to one Python file symbol by symbol; overlaps,
|
|
144
|
+
import or constant changes, and symbols changed on the target meanwhile are a conflict, never a guess.
|
|
145
|
+
- `oracle_cmd`: after a passing `verify`, runs the command on the job's base and on its result at the same
|
|
146
|
+
time and reports whether exit code and stdout match, plus `duration_ms_before`, `duration_ms_after`,
|
|
147
|
+
`speedup_pct`.
|
|
136
148
|
- `tier`: `fast` (effort low), `deep` (effort high, kind hard), `free_only`. Past 75% of the monthly cap,
|
|
137
149
|
`auto` and `balanced` go to free engines and the reply carries `budget_warning`.
|
|
138
150
|
|
|
@@ -80,9 +80,11 @@ redaction is a second layer, not a guarantee. See [SECURITY.md](SECURITY.md).
|
|
|
80
80
|
|
|
81
81
|
| tool | what it does |
|
|
82
82
|
|---|---|
|
|
83
|
-
| `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify, isolation, max_retries, adversarial, context_refs, auto_repomap, tier)` | start one job (see below) |
|
|
84
|
-
| `start_many(tasks)` | start several; partial success is listed per item. Items with `id` and `depends_on` form a group (`grp-…`) run as a DAG without extra turns |
|
|
83
|
+
| `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify, isolation, max_retries, adversarial, context_refs, auto_repomap, tier, invariants, allowed_paths, symbol_scope, oracle_cmd)` | start one job (see below) |
|
|
84
|
+
| `start_many(tasks)` | start several; partial success is listed per item. Items with `id` and `depends_on` form a group (`grp-…`) run as a DAG without extra turns; `foreach=<parent id>` makes one copy per item of the parent's `.subtask/fanout.json` (at most 8), and dependents wait for every copy; `after="any"` starts when one parent succeeded |
|
|
85
85
|
| `integrate(ids, action="apply", auto_resolve=False)` | worktree jobs back: `diff`, `apply` (all or nothing, uncommitted), `discard`; `auto_resolve` starts a reconciliation job on conflict |
|
|
86
|
+
| `consensus(prompt, engines, verify_cmd, isolation="worktree")` | 2 to 4 engines solve it blind in their own worktrees, then one job compares their diffs as anonymous patches and writes one solution, checked by `verify_cmd` and the invariants; returns a group id |
|
|
87
|
+
| `bisect(good_ref, bad_ref, test_cmd, parallelism=4, auto_fix=False)` | first commit that makes `test_cmd` fail, testing up to `parallelism` (max 8) commits per round in temporary worktrees; returns the commit, author and diff; `auto_fix` starts a verified fix job |
|
|
86
88
|
| `map_reduce(glob_pattern, prompt_template, batch_size=5, concurrency=3, verify_cmd, isolation="worktree")` | one job per batch of files (`{{files}}` in the template), at most `concurrency` at a time; returns a group id |
|
|
87
89
|
| `wait(ids, timeout=300, mode="all", since=None, stall_timeout=None)` | block until they end (`any`: the first; `first_success`: the first done and verified, the others are stopped); returns early on a stalled job; progress notifications while waiting; `since` cursor for clients without progress |
|
|
88
90
|
| `steer(id, action, instruction=None)` | `hint` (queued, sent as a reply when the attempt ends), `abort`, `retry` (fresh job with the instruction) |
|
|
@@ -102,6 +104,16 @@ Options of `start`:
|
|
|
102
104
|
Builder's worktree and run with `verify`.
|
|
103
105
|
- `context_refs`: `file:PATH[:A-B]`, `git:diff[:REF]`, `subtask:ID:summary|diff`, resolved before start.
|
|
104
106
|
`auto_repomap=True` adds tracked files with their top-level symbols (cached per HEAD).
|
|
107
|
+
- `invariants` (default `["no_test_deletion", "no_suppression"]`, `[]` turns them off): before `verify` counts,
|
|
108
|
+
test functions may not decrease, no new skip/xfail markers, no new `# type: ignore`, `# noqa` or
|
|
109
|
+
`# pragma: no cover`. A violation is a failed check with the reason, so a retry sees it.
|
|
110
|
+
- `allowed_paths`: files changed outside these paths are put back and the revert is committed.
|
|
111
|
+
- `symbol_scope=["path.py::name", ...]`: only those top-level functions or classes may change in those
|
|
112
|
+
files. `integrate` then merges several jobs' edits to one Python file symbol by symbol; overlaps,
|
|
113
|
+
import or constant changes, and symbols changed on the target meanwhile are a conflict, never a guess.
|
|
114
|
+
- `oracle_cmd`: after a passing `verify`, runs the command on the job's base and on its result at the same
|
|
115
|
+
time and reports whether exit code and stdout match, plus `duration_ms_before`, `duration_ms_after`,
|
|
116
|
+
`speedup_pct`.
|
|
105
117
|
- `tier`: `fast` (effort low), `deep` (effort high, kind hard), `free_only`. Past 75% of the monthly cap,
|
|
106
118
|
`auto` and `balanced` go to free engines and the reply carries `budget_warning`.
|
|
107
119
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Merge Python files by top-level symbol instead of by line.
|
|
2
|
+
|
|
3
|
+
Each job's change to a file is read as a set of replaced, added or removed
|
|
4
|
+
top-level functions and classes. When the jobs' sets do not overlap, and none
|
|
5
|
+
of them changes module-level statements (imports, constants), the symbols are
|
|
6
|
+
grafted onto the base text one by one. Anything else is a conflict: nothing is
|
|
7
|
+
guessed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
MODULE = "<module>"
|
|
16
|
+
DEFS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Conflict(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _lines(text: str) -> list[str]:
|
|
24
|
+
"""Lines as ast counts them: only \\n ends a line (splitlines also splits on form feeds)."""
|
|
25
|
+
if re.search(r"\r(?!\n)", text):
|
|
26
|
+
raise SyntaxError("lone carriage return")
|
|
27
|
+
return re.findall(r"[^\n]*\n|[^\n]+$", text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _main_guard(tree: ast.Module) -> int | None:
|
|
31
|
+
"""First line of a top-level `if __name__ == "__main__":`, where new symbols must go before."""
|
|
32
|
+
for node in tree.body:
|
|
33
|
+
test = getattr(node, "test", None)
|
|
34
|
+
is_compare = isinstance(node, ast.If) and isinstance(test, ast.Compare)
|
|
35
|
+
if is_compare and isinstance(test.left, ast.Name) and test.left.id == "__name__":
|
|
36
|
+
return node.lineno
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parts(text: str) -> tuple[dict[str, tuple[int, int, str]], list[str]]:
|
|
41
|
+
"""{name: (first line, last line, source)} for top-level defs, and everything else.
|
|
42
|
+
|
|
43
|
+
A second def with the same name is keyed name#2. Everything outside the defs (statements,
|
|
44
|
+
comments) is kept as non-blank text, so a change there is a module-level change.
|
|
45
|
+
"""
|
|
46
|
+
tree = ast.parse(text)
|
|
47
|
+
lines = _lines(text)
|
|
48
|
+
defs: dict[str, tuple[int, int, str]] = {}
|
|
49
|
+
taken = [False] * (len(lines) + 1)
|
|
50
|
+
for node in tree.body:
|
|
51
|
+
if not isinstance(node, DEFS):
|
|
52
|
+
continue
|
|
53
|
+
start = min([node.lineno] + [d.lineno for d in node.decorator_list])
|
|
54
|
+
end = node.end_lineno or node.lineno
|
|
55
|
+
name, n = node.name, 1
|
|
56
|
+
while name in defs:
|
|
57
|
+
n += 1
|
|
58
|
+
name = f"{node.name}#{n}"
|
|
59
|
+
defs[name] = (start, end, "".join(lines[start - 1 : end]))
|
|
60
|
+
for i in range(start, end + 1):
|
|
61
|
+
taken[i] = True
|
|
62
|
+
other = [line.strip() for i, line in enumerate(lines, 1) if not taken[i] and line.strip()]
|
|
63
|
+
return defs, other
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def changed_symbols(old: str, new: str) -> set[str]:
|
|
67
|
+
"""Top-level names whose source differs; MODULE when other statements differ. Raises SyntaxError."""
|
|
68
|
+
old_defs, old_other = _parts(old) if old else ({}, [])
|
|
69
|
+
new_defs, new_other = _parts(new) if new else ({}, [])
|
|
70
|
+
names = {
|
|
71
|
+
n
|
|
72
|
+
for n in old_defs.keys() | new_defs.keys()
|
|
73
|
+
if (old_defs.get(n) or (0, 0, ""))[2] != (new_defs.get(n) or (0, 0, ""))[2]
|
|
74
|
+
}
|
|
75
|
+
if old_other != new_other:
|
|
76
|
+
names.add(MODULE)
|
|
77
|
+
return names
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def graft(base: str, versions: list[tuple[str, str, str]]) -> str:
|
|
81
|
+
"""Apply each (job id, its base text, its new text) symbol by symbol onto base. Raises Conflict."""
|
|
82
|
+
try:
|
|
83
|
+
base_defs, _ = _parts(base)
|
|
84
|
+
except SyntaxError as exc:
|
|
85
|
+
raise Conflict(f"base is not valid Python ({exc.msg})") from exc
|
|
86
|
+
owner: dict[str, str] = {}
|
|
87
|
+
edits: list[tuple[str, str | None]] = [] # (name, new source or None for removed)
|
|
88
|
+
for job_id, old, text in versions:
|
|
89
|
+
try:
|
|
90
|
+
new_defs, _ = _parts(text)
|
|
91
|
+
old_defs, _ = _parts(old) if old else ({}, [])
|
|
92
|
+
names = changed_symbols(old, text)
|
|
93
|
+
except SyntaxError as exc:
|
|
94
|
+
raise Conflict(f"{job_id}: not valid Python ({exc.msg})") from exc
|
|
95
|
+
if MODULE in names:
|
|
96
|
+
raise Conflict(f"{job_id} changes module-level statements (imports or constants)")
|
|
97
|
+
for name in names:
|
|
98
|
+
if name in owner:
|
|
99
|
+
raise Conflict(f"{owner[name]} and {job_id} both change {name}")
|
|
100
|
+
owner[name] = job_id
|
|
101
|
+
if (base_defs.get(name) or (0, 0, ""))[2] != (old_defs.get(name) or (0, 0, ""))[2]:
|
|
102
|
+
raise Conflict(f"{name} changed on the target too since {job_id} started")
|
|
103
|
+
edits.append((name, new_defs[name][2] if name in new_defs else None))
|
|
104
|
+
lines = _lines(base)
|
|
105
|
+
added = "".join(
|
|
106
|
+
source.rstrip("\n") + "\n\n\n"
|
|
107
|
+
for _, source in sorted(e for e in edits if e[0] not in base_defs)
|
|
108
|
+
if source
|
|
109
|
+
)
|
|
110
|
+
guard = _main_guard(ast.parse(base))
|
|
111
|
+
# (first line, last line, new text); applied bottom-up so earlier line numbers stay valid
|
|
112
|
+
ops = [(base_defs[n][0], base_defs[n][1], src or "") for n, src in edits if n in base_defs]
|
|
113
|
+
if added and guard is not None: # before the main block, which may call them
|
|
114
|
+
ops.append((guard, guard - 1, added))
|
|
115
|
+
for start, end, src in sorted(ops, key=lambda o: (-o[0], -o[1])):
|
|
116
|
+
lines[start - 1 : end] = [src] if src else []
|
|
117
|
+
text = "".join(lines)
|
|
118
|
+
if added and guard is None:
|
|
119
|
+
text = text.rstrip("\n") + "\n\n\n" + added.rstrip("\n") + "\n"
|
|
120
|
+
try:
|
|
121
|
+
ast.parse(text)
|
|
122
|
+
except SyntaxError as exc:
|
|
123
|
+
raise Conflict(f"merged file is not valid Python ({exc.msg})") from exc
|
|
124
|
+
return text
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""K-ary parallel bisect: test up to K commits at once to find the first one that breaks a check.
|
|
2
|
+
|
|
3
|
+
The commits between good (passes) and bad (fails) are taken in first-parent
|
|
4
|
+
order. Each round tests K evenly spaced commits in temporary worktrees, at the
|
|
5
|
+
same time, and keeps the interval between the last pass and the first failure.
|
|
6
|
+
Rounds needed: about log_(K+1) of the commit count. Worktrees are always removed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import secrets
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
|
|
17
|
+
from . import config, jobs
|
|
18
|
+
|
|
19
|
+
TIMEOUT_S = 900
|
|
20
|
+
MAX_COMMITS = 5000
|
|
21
|
+
MAX_DIFF = 12000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _git(repo: str, *args: str) -> subprocess.CompletedProcess:
|
|
25
|
+
return subprocess.run(
|
|
26
|
+
["git", "-C", repo, *args], capture_output=True, text=True, timeout=120, check=False
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _passes(repo: str, commit: str, cmd: str) -> bool:
|
|
31
|
+
scratch = str(config.HOME / "bisect" / secrets.token_hex(4))
|
|
32
|
+
try:
|
|
33
|
+
if _git(repo, "worktree", "add", "-q", "--detach", scratch, commit).returncode != 0:
|
|
34
|
+
raise RuntimeError(f"worktree for {commit[:9]} not created")
|
|
35
|
+
try:
|
|
36
|
+
done = subprocess.run(
|
|
37
|
+
["sh", "-c", cmd], cwd=scratch, capture_output=True, timeout=TIMEOUT_S, check=False,
|
|
38
|
+
stdin=subprocess.DEVNULL,
|
|
39
|
+
) # fmt: skip
|
|
40
|
+
return done.returncode == 0
|
|
41
|
+
except subprocess.TimeoutExpired:
|
|
42
|
+
return False
|
|
43
|
+
finally:
|
|
44
|
+
_git(repo, "worktree", "remove", "--force", scratch)
|
|
45
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _note(run: str, **fields) -> None:
|
|
49
|
+
"""Progress a client panel can show while the bisect runs (subtask board)."""
|
|
50
|
+
path = config.HOME / "bisect-runs" / f"{run}.json"
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
if not path.exists(): # keep the folder small: the 20 newest runs
|
|
53
|
+
for stale in sorted(path.parent.glob("*.json"), key=lambda p: p.stat().st_mtime)[:-19]:
|
|
54
|
+
stale.unlink(missing_ok=True)
|
|
55
|
+
old = jobs.read_json(path) if path.is_file() else {}
|
|
56
|
+
jobs.write_json(path, {**old, **fields, "updated": time.time()})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def recent(limit: int = 3, max_age_s: float = 86400) -> list[dict]:
|
|
60
|
+
folder = config.HOME / "bisect-runs"
|
|
61
|
+
if not folder.is_dir():
|
|
62
|
+
return []
|
|
63
|
+
rows = [
|
|
64
|
+
jobs.read_json(p)
|
|
65
|
+
for p in sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
66
|
+
]
|
|
67
|
+
return [r for r in rows if time.time() - float(r.get("updated") or 0) < max_age_s][:limit]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find(repo: str, good: str, bad: str, cmd: str, parallelism: int = 4) -> dict:
|
|
71
|
+
"""{'commit', 'rounds', 'tested'} or {'error'}."""
|
|
72
|
+
resolved = []
|
|
73
|
+
for ref in (good, bad):
|
|
74
|
+
if ref.startswith("-"):
|
|
75
|
+
return {"error": f"invalid ref: {ref}"}
|
|
76
|
+
done = _git(repo, "rev-parse", "--verify", "--end-of-options", ref + "^{commit}")
|
|
77
|
+
if done.returncode != 0:
|
|
78
|
+
return {"error": f"unknown ref: {ref}"}
|
|
79
|
+
resolved.append(done.stdout.strip())
|
|
80
|
+
good_sha, bad_sha = resolved
|
|
81
|
+
if _git(repo, "merge-base", "--is-ancestor", good_sha, bad_sha).returncode != 0:
|
|
82
|
+
return {"error": "good_ref is not an ancestor of bad_ref"}
|
|
83
|
+
commits = _git(repo, "rev-list", "--first-parent", "--reverse", f"{good_sha}..{bad_sha}").stdout.split()
|
|
84
|
+
if not commits or len(commits) > MAX_COMMITS:
|
|
85
|
+
return {"error": f"{len(commits)} commits between good and bad (1 to {MAX_COMMITS} allowed)"}
|
|
86
|
+
k = max(1, min(int(parallelism), 8))
|
|
87
|
+
run = "bis-" + secrets.token_hex(4)
|
|
88
|
+
_note(run, id=run, test_cmd=cmd, candidates=len(commits), round=0, tested=0, state="running")
|
|
89
|
+
try:
|
|
90
|
+
return _search(repo, commits, cmd, k, run)
|
|
91
|
+
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
92
|
+
_note(run, state="failed", error=str(exc)[:200])
|
|
93
|
+
return {"error": f"bisect stopped: {exc}"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _search(repo: str, commits: list[str], cmd: str, k: int, run: str) -> dict:
|
|
97
|
+
lo, hi = -1, len(commits) - 1 # commits[lo] passes (-1 = good), commits[hi] fails (bad)
|
|
98
|
+
rounds, tested = 0, 0
|
|
99
|
+
with ThreadPoolExecutor(k) as pool:
|
|
100
|
+
confirmed = False # bad_ref itself is tested in the first round
|
|
101
|
+
while hi - lo > 1 or not confirmed:
|
|
102
|
+
span = hi - lo - 1
|
|
103
|
+
picks = sorted({lo + 1 + (span * (i + 1)) // (k + 1) for i in range(min(k, span))})
|
|
104
|
+
if not confirmed:
|
|
105
|
+
picks = sorted(set(picks) | {hi})
|
|
106
|
+
results = dict(zip(picks, pool.map(lambda i: _passes(repo, commits[i], cmd), picks)))
|
|
107
|
+
if not confirmed:
|
|
108
|
+
confirmed = True
|
|
109
|
+
if results[hi]:
|
|
110
|
+
_note(run, state="failed", error="test_cmd passes on bad_ref")
|
|
111
|
+
return {"error": "test_cmd passes on bad_ref: nothing to find"}
|
|
112
|
+
rounds, tested = rounds + 1, tested + len(picks)
|
|
113
|
+
_note(run, round=rounds, tested=tested, left=max(hi - lo - 1, 0))
|
|
114
|
+
for i in picks: # the interval ends at the first failing pick
|
|
115
|
+
if results[i]:
|
|
116
|
+
lo = i
|
|
117
|
+
else:
|
|
118
|
+
hi = i
|
|
119
|
+
break
|
|
120
|
+
_note(run, state="done", commit=commits[hi], round=rounds, tested=tested, left=0)
|
|
121
|
+
return {"commit": commits[hi], "rounds": rounds, "tested": tested, "candidates": len(commits)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def report(repo: str, found: dict) -> str:
|
|
125
|
+
if found.get("error"):
|
|
126
|
+
return found["error"]
|
|
127
|
+
sha = found["commit"]
|
|
128
|
+
meta = _git(repo, "show", "-s", "--format=%H%n%an <%ae>%n%ad%n%s", sha).stdout.strip()
|
|
129
|
+
diff = _git(repo, "show", "--stat", "--patch", "--format=", sha).stdout
|
|
130
|
+
diff = diff[:MAX_DIFF] + ("\n… diff cut" if len(diff) > MAX_DIFF else "")
|
|
131
|
+
head = (
|
|
132
|
+
f"first bad commit: {meta}\n"
|
|
133
|
+
f"{found['candidates']} candidates, {found['tested']} tested in {found['rounds']} parallel rounds"
|
|
134
|
+
)
|
|
135
|
+
return head + "\n\n" + diff
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
FIX_TASK = (
|
|
139
|
+
"Commit {sha} made `{cmd}` fail. Fix the regression without undoing what that commit was for. "
|
|
140
|
+
"Run the check, commit, and finish with a ## Summary.\n\nThe commit:\n{diff}"
|
|
141
|
+
)
|
|
@@ -117,6 +117,9 @@ def main() -> int:
|
|
|
117
117
|
from . import stephook
|
|
118
118
|
|
|
119
119
|
return stephook.main()
|
|
120
|
+
if a.cmd == "install-skill":
|
|
121
|
+
print("\n".join(install_commands.install_skill(a.dest, a.client)))
|
|
122
|
+
return 0
|
|
120
123
|
if a.cmd in ("install", "uninstall") or (a.cmd == "doctor" and a.client):
|
|
121
124
|
return install_commands.run(a)
|
|
122
125
|
|
|
@@ -97,6 +97,10 @@ def start(
|
|
|
97
97
|
context_refs: list[str] | None = None,
|
|
98
98
|
auto_repomap: bool = False,
|
|
99
99
|
tier: str = "auto",
|
|
100
|
+
invariants: list[str] | None = None,
|
|
101
|
+
allowed_paths: list[str] | None = None,
|
|
102
|
+
symbol_scope: list[str] | None = None,
|
|
103
|
+
oracle_cmd: str | None = None,
|
|
100
104
|
_breaker: str | None = None,
|
|
101
105
|
_group: str | None = None,
|
|
102
106
|
) -> str:
|
|
@@ -112,6 +116,17 @@ def start(
|
|
|
112
116
|
return "max_retries needs verify"
|
|
113
117
|
if tier not in TIERS:
|
|
114
118
|
return "tier must be " + "|".join(TIERS)
|
|
119
|
+
from . import invariants as inv
|
|
120
|
+
|
|
121
|
+
problem = inv.validate(invariants, allowed_paths, symbol_scope)
|
|
122
|
+
if problem:
|
|
123
|
+
return problem
|
|
124
|
+
if oracle_cmd is not None and not (isinstance(oracle_cmd, str) and oracle_cmd.strip()):
|
|
125
|
+
return "oracle_cmd must be a non-empty command"
|
|
126
|
+
if oracle_cmd and not (repo or isolation == "worktree") or oracle_cmd and readonly:
|
|
127
|
+
return "oracle_cmd needs a worktree (repo or isolation=worktree)"
|
|
128
|
+
if (allowed_paths or symbol_scope) and not (repo or isolation == "worktree"):
|
|
129
|
+
return "allowed_paths and symbol_scope need a worktree (repo or isolation=worktree)"
|
|
115
130
|
tier, budget_warning = _degrade(tier)
|
|
116
131
|
if tier == "fast" and effort == "medium":
|
|
117
132
|
effort = "low"
|
|
@@ -147,7 +162,9 @@ def start(
|
|
|
147
162
|
breaker = broke.split()[0]
|
|
148
163
|
built = start(
|
|
149
164
|
task, engine, repo, None, base, False, max_usd, max_turns, effort, model, sandbox, kind,
|
|
150
|
-
files, policy, verify, untrusted, progress, max_retries=max_retries,
|
|
165
|
+
files, policy, verify, untrusted, progress, max_retries=max_retries, invariants=invariants,
|
|
166
|
+
allowed_paths=allowed_paths, symbol_scope=symbol_scope, oracle_cmd=oracle_cmd, _breaker=breaker,
|
|
167
|
+
_group=_group,
|
|
151
168
|
) # fmt: skip
|
|
152
169
|
if "already running" in built: # no slot now: a group starts the Builder when one frees
|
|
153
170
|
from . import groups
|
|
@@ -156,7 +173,8 @@ def start(
|
|
|
156
173
|
"task": task, "engine": engine, "repo": repo, "base": base, "max_usd": max_usd,
|
|
157
174
|
"max_turns": max_turns, "effort": effort, "model": model, "kind": kind, "files": files,
|
|
158
175
|
"policy": policy, "verify": verify, "untrusted": untrusted, "max_retries": max_retries,
|
|
159
|
-
"
|
|
176
|
+
"invariants": invariants, "allowed_paths": allowed_paths, "symbol_scope": symbol_scope,
|
|
177
|
+
"oracle_cmd": oracle_cmd, "_breaker": breaker,
|
|
160
178
|
}]) # fmt: skip
|
|
161
179
|
return f"breaker {broke}\nbuilder queued in {queued.split()[0]}"
|
|
162
180
|
if " started " not in built:
|
|
@@ -220,6 +238,12 @@ def start(
|
|
|
220
238
|
except subprocess.CalledProcessError as exc:
|
|
221
239
|
raise _Compensate(f"worktree not created: {exc.stderr.strip()[:200]}") from exc
|
|
222
240
|
task += f"\n\nYou work in an isolated git worktree on branch {branch}. Commit your work there; never push."
|
|
241
|
+
if allowed_paths:
|
|
242
|
+
task += "\nChange only these paths; anything else is reverted: " + ", ".join(allowed_paths)
|
|
243
|
+
if symbol_scope:
|
|
244
|
+
task += "\nIn those Python files change only these top-level symbols: " + ", ".join(
|
|
245
|
+
symbol_scope
|
|
246
|
+
)
|
|
223
247
|
else:
|
|
224
248
|
workdir = str(Path(cwd or repo or os.getcwd()).resolve())
|
|
225
249
|
if not Path(workdir).is_dir():
|
|
@@ -243,6 +267,10 @@ def start(
|
|
|
243
267
|
"policy": {**wanted, "effective": effective, "why": why},
|
|
244
268
|
"verify": verify,
|
|
245
269
|
"max_retries": max_retries,
|
|
270
|
+
"invariants": inv.DEFAULT if invariants is None else invariants,
|
|
271
|
+
"allowed_paths": allowed_paths,
|
|
272
|
+
"symbol_scope": symbol_scope,
|
|
273
|
+
"oracle_cmd": oracle_cmd,
|
|
246
274
|
"breaker": _breaker,
|
|
247
275
|
"port": _free_port() if branch else None,
|
|
248
276
|
"group": _group,
|
|
@@ -559,7 +587,8 @@ def wait(
|
|
|
559
587
|
|
|
560
588
|
for group_id in group_ids:
|
|
561
589
|
groups.cancel(group_id, f"cancelled: first_success {winner}")
|
|
562
|
-
|
|
590
|
+
job_ids, _ = _expand(asked) # a group job started while we looked is a loser too
|
|
591
|
+
losers = [j for j in job_ids if j != winner and jobs.status(j).get("state") not in jobs.FINAL]
|
|
563
592
|
for job_id in losers:
|
|
564
593
|
jobs.stop(job_id)
|
|
565
594
|
head = f"first success: {winner}" + (f" · stopped {', '.join(losers)}" if losers else "")
|
|
@@ -571,6 +600,83 @@ def wait(
|
|
|
571
600
|
return ("no job succeeded\n\n" + out) if mode == "first_success" else out
|
|
572
601
|
|
|
573
602
|
|
|
603
|
+
def bisect(
|
|
604
|
+
good_ref: str,
|
|
605
|
+
bad_ref: str,
|
|
606
|
+
test_cmd: str,
|
|
607
|
+
parallelism: int = 4,
|
|
608
|
+
auto_fix: bool = False,
|
|
609
|
+
repo: str | None = None,
|
|
610
|
+
engine: str = "auto",
|
|
611
|
+
max_usd: float | None = None,
|
|
612
|
+
) -> str:
|
|
613
|
+
"""Find the first commit between good_ref and bad_ref that makes test_cmd fail."""
|
|
614
|
+
from . import bisect_tool
|
|
615
|
+
|
|
616
|
+
if not (isinstance(test_cmd, str) and test_cmd.strip()):
|
|
617
|
+
return "test_cmd must be a non-empty command"
|
|
618
|
+
if isinstance(parallelism, bool) or not isinstance(parallelism, int) or not 1 <= parallelism <= 8:
|
|
619
|
+
return "parallelism must be between 1 and 8"
|
|
620
|
+
root = _repo_of(str(Path(repo or os.getcwd()).resolve()))
|
|
621
|
+
if not root:
|
|
622
|
+
return "bisect needs a git repository"
|
|
623
|
+
found = bisect_tool.find(root, good_ref, bad_ref, test_cmd, parallelism)
|
|
624
|
+
text = bisect_tool.report(root, found)
|
|
625
|
+
if found.get("error") or not auto_fix:
|
|
626
|
+
return text
|
|
627
|
+
diff = text.split("\n\n", 1)[-1]
|
|
628
|
+
task = bisect_tool.FIX_TASK.format(sha=found["commit"][:12], cmd=test_cmd, diff=diff)
|
|
629
|
+
out = start(task, engine, root, base="HEAD", max_usd=max_usd, verify=test_cmd, max_retries=1)
|
|
630
|
+
return text[:2000] + f"\n\nfix job: {out}"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
CONSENSUS_TASK = (
|
|
634
|
+
"Several engines solved the task below independently, each on its own branch. Their results are "
|
|
635
|
+
"attached as Patch A, Patch B, ... (summary and diff). Compare them, keep the strongest ideas, drop "
|
|
636
|
+
"the bugs of each, and write ONE solution in your worktree. Do not merge the branches. Commit, run "
|
|
637
|
+
"the checks, and finish with a ## Summary saying what you took from which patch.\n\nTask:\n"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def consensus(
|
|
642
|
+
prompt: str,
|
|
643
|
+
engines: list[str],
|
|
644
|
+
verify_cmd: str | None = None,
|
|
645
|
+
isolation: str = "worktree",
|
|
646
|
+
repo: str | None = None,
|
|
647
|
+
cwd: str | None = None,
|
|
648
|
+
max_usd: float | None = None,
|
|
649
|
+
synth_engine: str = "auto",
|
|
650
|
+
) -> str:
|
|
651
|
+
"""Round 1: every engine works blind in its own worktree. Round 2: one job merges the best."""
|
|
652
|
+
if isolation != "worktree":
|
|
653
|
+
return "consensus needs isolation=worktree"
|
|
654
|
+
if not isinstance(engines, list) or not 2 <= len(engines) <= 4 or len(set(engines)) != len(engines):
|
|
655
|
+
return "engines must list 2 to 4 different engines"
|
|
656
|
+
unknown = [e for e in engines if registry.get(e) is None]
|
|
657
|
+
if unknown:
|
|
658
|
+
return f"unknown engine: {', '.join(unknown)}"
|
|
659
|
+
if not (isinstance(prompt, str) and prompt.strip()):
|
|
660
|
+
return "prompt must be a non-empty string"
|
|
661
|
+
root = _repo_of(str(Path(repo or cwd or os.getcwd()).resolve()))
|
|
662
|
+
if not root:
|
|
663
|
+
return "consensus needs a git repository"
|
|
664
|
+
base = {"repo": root, "max_usd": max_usd, "verify": verify_cmd}
|
|
665
|
+
rounds = [{"id": f"r{i + 1}", "task": prompt, "engine": e, **base} for i, e in enumerate(engines)]
|
|
666
|
+
refs = []
|
|
667
|
+
for i in range(len(engines)):
|
|
668
|
+
refs += [f"subtask:r{i + 1}:summary", f"subtask:r{i + 1}:diff"]
|
|
669
|
+
labels = ", ".join(f"Patch {chr(65 + i)} = subtask:r{i + 1}:diff" for i in range(len(engines)))
|
|
670
|
+
synth = {
|
|
671
|
+
"id": "synthesis", "depends_on": [r["id"] for r in rounds], "after": "any", "engine": synth_engine,
|
|
672
|
+
"task": CONSENSUS_TASK + prompt + f"\n\n({labels}; the engine names are hidden on purpose.)",
|
|
673
|
+
"context_refs": refs, **base,
|
|
674
|
+
} # fmt: skip
|
|
675
|
+
from . import groups
|
|
676
|
+
|
|
677
|
+
return groups.create(rounds + [synth], label="consensus")
|
|
678
|
+
|
|
679
|
+
|
|
574
680
|
def start_many(tasks: list[dict], progress=None) -> str:
|
|
575
681
|
"""Start several jobs in one call; each dict takes start()'s arguments.
|
|
576
682
|
|
|
@@ -635,6 +741,12 @@ def result(job_id: str, full: bool = False, evidence: bool = False, _count: bool
|
|
|
635
741
|
except subprocess.CalledProcessError:
|
|
636
742
|
pass
|
|
637
743
|
verification = st.get("verification")
|
|
744
|
+
if verification and verification.get("oracle"):
|
|
745
|
+
from . import oracle
|
|
746
|
+
|
|
747
|
+
head += "\n" + oracle.line(verification["oracle"])
|
|
748
|
+
if verification and verification.get("invariants"):
|
|
749
|
+
head += "\ninvariants violated: " + "; ".join(verification["invariants"][:5])
|
|
638
750
|
if verification:
|
|
639
751
|
code = verification.get("exit_code")
|
|
640
752
|
head += (
|
|
@@ -933,6 +1045,16 @@ def steps(limit: int = 10) -> str:
|
|
|
933
1045
|
return "\n".join(f"{r['ts']} {Path(r['repo']).name}: {r['title']} ({r['path']})" for r in rows)
|
|
934
1046
|
|
|
935
1047
|
|
|
1048
|
+
def _oracle_short(report: dict | None) -> dict | None:
|
|
1049
|
+
if not report:
|
|
1050
|
+
return None
|
|
1051
|
+
if report.get("error"):
|
|
1052
|
+
return {"error": report["error"]}
|
|
1053
|
+
return {"output_match": bool(report.get("output_match") and report.get("exit_match")),
|
|
1054
|
+
"speedup_pct": report.get("speedup_pct"), "ms_before": report.get("duration_ms_before"),
|
|
1055
|
+
"ms_after": report.get("duration_ms_after")} # fmt: skip
|
|
1056
|
+
|
|
1057
|
+
|
|
936
1058
|
def board_data(limit: int = 10) -> dict:
|
|
937
1059
|
"""What a client panel shows: active groups with their queue, recent jobs, verify and budget."""
|
|
938
1060
|
from . import groups
|
|
@@ -947,7 +1069,8 @@ def board_data(limit: int = 10) -> dict:
|
|
|
947
1069
|
"id": path.stem, "kind": g.get("kind"), "label": g.get("label"),
|
|
948
1070
|
"summary": groups.summary(path.stem),
|
|
949
1071
|
"tasks": [{"alias": t["alias"], "state": t["state"], "job": t.get("job"),
|
|
950
|
-
"depends_on": t.get("depends_on", []), "why": t.get("why", "")
|
|
1072
|
+
"depends_on": t.get("depends_on", []), "why": t.get("why", ""),
|
|
1073
|
+
"expanded_from": t.get("expanded_from")} for t in tasks],
|
|
951
1074
|
}) # fmt: skip
|
|
952
1075
|
job_rows = []
|
|
953
1076
|
for job_id in jobs.all_jobs()[:limit]:
|
|
@@ -963,11 +1086,16 @@ def board_data(limit: int = 10) -> dict:
|
|
|
963
1086
|
"retrying": verifier.pending(st), "rolled_back_to": st.get("rolled_back_to"),
|
|
964
1087
|
"stalled": watchdog.check(job_id, st), "started": sp.get("created"),
|
|
965
1088
|
"budget_warning": sp.get("budget_warning"),
|
|
1089
|
+
"invariants": v.get("invariants") or [], "reverted": v.get("reverted") or [],
|
|
1090
|
+
"oracle": _oracle_short(v.get("oracle")),
|
|
966
1091
|
}) # fmt: skip
|
|
1092
|
+
from . import bisect_tool
|
|
1093
|
+
|
|
967
1094
|
spent = ledger.committed()
|
|
968
1095
|
return {
|
|
969
1096
|
"groups": group_rows,
|
|
970
1097
|
"jobs": job_rows,
|
|
1098
|
+
"bisects": bisect_tool.recent(),
|
|
971
1099
|
"budget": {"cap_usd": config.MONTHLY_CAP_USD, "committed_usd": round(spent, 2),
|
|
972
1100
|
"left_usd": round(config.MONTHLY_CAP_USD - spent, 2)},
|
|
973
1101
|
} # fmt: skip
|
|
@@ -980,8 +1108,9 @@ def board(limit: int = 10) -> str:
|
|
|
980
1108
|
lines.append(f"{g['id']} {g['kind']} {g['label'] or ''} · {g['summary']}".replace(" ", " "))
|
|
981
1109
|
for t in g["tasks"]:
|
|
982
1110
|
deps = f" ← {', '.join(t['depends_on'])}" if t["depends_on"] else ""
|
|
1111
|
+
fan = f" (from {t['expanded_from']})" if t.get("expanded_from") else ""
|
|
983
1112
|
lines.append(
|
|
984
|
-
f" {t['alias']}: {t['job'] or t['state']}{deps}{' · ' + t['why'] if t['why'] else ''}"
|
|
1113
|
+
f" {t['alias']}{fan}: {t['job'] or t['state']}{deps}{' · ' + t['why'] if t['why'] else ''}"
|
|
985
1114
|
)
|
|
986
1115
|
for j in data["jobs"]:
|
|
987
1116
|
bits = [j["state"], j["engine"] or "?"]
|
|
@@ -993,7 +1122,21 @@ def board(limit: int = 10) -> str:
|
|
|
993
1122
|
bits.append(f"rolled back to {j['rolled_back_to']}")
|
|
994
1123
|
if j["stalled"]:
|
|
995
1124
|
bits.append(f"STALLED {j['stalled']}")
|
|
1125
|
+
if j["invariants"]:
|
|
1126
|
+
bits.append(f"INVARIANT {j['invariants'][0][:60]}")
|
|
1127
|
+
if j["reverted"]:
|
|
1128
|
+
bits.append(f"reverted {len(j['reverted'])} file(s) outside allowed_paths")
|
|
1129
|
+
o = j["oracle"]
|
|
1130
|
+
if o:
|
|
1131
|
+
same = "error" if o.get("error") else "same output" if o["output_match"] else "OUTPUT DIFFERS"
|
|
1132
|
+
pct = o.get("speedup_pct")
|
|
1133
|
+
bits.append(f"oracle {same}" + ("" if pct is None else f" {pct:+.0f}%"))
|
|
996
1134
|
lines.append(f"{j['id']} " + " · ".join(bits))
|
|
1135
|
+
for b in data["bisects"]:
|
|
1136
|
+
found = f" → {b['commit'][:9]}" if b.get("commit") else ""
|
|
1137
|
+
lines.append(
|
|
1138
|
+
f"{b['id']} bisect {b['state']} · round {b['round']} · {b['tested']}/{b['candidates']} tested{found}"
|
|
1139
|
+
)
|
|
997
1140
|
b = data["budget"]
|
|
998
1141
|
lines.append(f"budget: {b['left_usd']:.2f}$ left of {b['cap_usd']:.0f}$")
|
|
999
1142
|
return "\n".join(lines)
|
|
@@ -132,6 +132,16 @@ def _call(name: str, args: dict, call: Call) -> str:
|
|
|
132
132
|
from . import integrate
|
|
133
133
|
|
|
134
134
|
return integrate.integrate(args["ids"], args.get("action", "apply"), args.get("auto_resolve", False))
|
|
135
|
+
if name == "consensus":
|
|
136
|
+
known = {"prompt", "engines", "verify_cmd", "isolation", "repo", "max_usd"}
|
|
137
|
+
if set(args) - known:
|
|
138
|
+
raise TypeError(f"unexpected argument(s): {', '.join(sorted(set(args) - known))}")
|
|
139
|
+
return core.consensus(**args)
|
|
140
|
+
if name == "bisect":
|
|
141
|
+
known = {"good_ref", "bad_ref", "test_cmd", "parallelism", "auto_fix", "repo"}
|
|
142
|
+
if set(args) - known:
|
|
143
|
+
raise TypeError(f"unexpected argument(s): {', '.join(sorted(set(args) - known))}")
|
|
144
|
+
return core.bisect(**args)
|
|
135
145
|
if name == "inspect":
|
|
136
146
|
return core.inspect(args["id"], args.get("tail", 80), args.get("grep"))
|
|
137
147
|
if name == "steer":
|
|
@@ -155,7 +165,7 @@ def _call(name: str, args: dict, call: Call) -> str:
|
|
|
155
165
|
START_ARGS = {
|
|
156
166
|
"task", "kind", "repo", "cwd", "files", "readonly", "engine", "max_usd", "effort",
|
|
157
167
|
"base", "max_turns", "policy", "verify", "isolation", "max_retries", "adversarial",
|
|
158
|
-
"context_refs", "auto_repomap", "tier",
|
|
168
|
+
"context_refs", "auto_repomap", "tier", "invariants", "allowed_paths", "symbol_scope", "oracle_cmd",
|
|
159
169
|
} # fmt: skip
|
|
160
170
|
|
|
161
171
|
|