subtask-mcp 0.2.3__py3-none-any.whl
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/__init__.py +3 -0
- subtask/admission.py +254 -0
- subtask/capacity.py +90 -0
- subtask/cli.py +183 -0
- subtask/config.py +59 -0
- subtask/core.py +603 -0
- subtask/doctor.py +120 -0
- subtask/engines/__init__.py +1 -0
- subtask/engines/base.py +297 -0
- subtask/engines/claude_api.py +204 -0
- subtask/engines/codex.py +182 -0
- subtask/engines/demo.py +50 -0
- subtask/engines/dsh.py +96 -0
- subtask/engines/kimi.py +75 -0
- subtask/engines/opencode.py +87 -0
- subtask/engines/registry.py +124 -0
- subtask/events.py +105 -0
- subtask/evidence.py +103 -0
- subtask/fastserver.py +272 -0
- subtask/gc.py +99 -0
- subtask/install/__init__.py +1 -0
- subtask/install/clients.py +355 -0
- subtask/install/commands.py +122 -0
- subtask/install/doctor.py +112 -0
- subtask/install/fileops.py +69 -0
- subtask/install/installer.py +204 -0
- subtask/install/ownership.py +44 -0
- subtask/install/tomledit.py +44 -0
- subtask/jobs.py +256 -0
- subtask/ledger.py +268 -0
- subtask/narrate.py +134 -0
- subtask/policy.py +58 -0
- subtask/procid.py +97 -0
- subtask/progress.py +152 -0
- subtask/redact.py +61 -0
- subtask/roi.py +84 -0
- subtask/router.py +202 -0
- subtask/runners.py +99 -0
- subtask/schemacheck.py +131 -0
- subtask/server.py +192 -0
- subtask/skill/SKILL.md +96 -0
- subtask/telemetry.py +76 -0
- subtask/tools.json +237 -0
- subtask/worker.py +278 -0
- subtask_mcp-0.2.3.dist-info/METADATA +164 -0
- subtask_mcp-0.2.3.dist-info/RECORD +50 -0
- subtask_mcp-0.2.3.dist-info/WHEEL +5 -0
- subtask_mcp-0.2.3.dist-info/entry_points.txt +4 -0
- subtask_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
- subtask_mcp-0.2.3.dist-info/top_level.txt +1 -0
subtask/schemacheck.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Semantic checks for published tool schemas, independent of how they were generated.
|
|
2
|
+
|
|
3
|
+
The slimming in server.py once dropped every key named "title", including the
|
|
4
|
+
real `step.title` parameter: the published schema then required a property it
|
|
5
|
+
never declared. A parity test could not see it, because both sides went
|
|
6
|
+
through the same transformation. These checks look at the meaning instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
TYPES = {"string", "number", "integer", "boolean", "object", "array", "null"}
|
|
14
|
+
MAX_DEPTH = 32
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def problems(schema: Any, where: str = "$") -> list[str]:
|
|
18
|
+
"""Every semantic defect found in a JSON Schema object (2020-12 subset)."""
|
|
19
|
+
out: list[str] = []
|
|
20
|
+
_walk(schema, schema, where, out, 0)
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve(root: dict, ref: str) -> Any:
|
|
25
|
+
if not ref.startswith("#/"):
|
|
26
|
+
raise ValueError(f"non-local $ref not allowed: {ref}")
|
|
27
|
+
node: Any = root
|
|
28
|
+
for part in ref[2:].split("/"):
|
|
29
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
30
|
+
if not isinstance(node, dict) or part not in node:
|
|
31
|
+
raise KeyError(ref)
|
|
32
|
+
node = node[part]
|
|
33
|
+
return node
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _declared(root: dict, schema: dict, seen: frozenset = frozenset()) -> set[str] | None:
|
|
37
|
+
"""Property names an object schema declares, following $ref and allOf. None = open."""
|
|
38
|
+
if "$ref" in schema:
|
|
39
|
+
ref = schema["$ref"]
|
|
40
|
+
if ref in seen:
|
|
41
|
+
return set()
|
|
42
|
+
return _declared(root, _resolve(root, ref), seen | {ref})
|
|
43
|
+
names = set(schema.get("properties", {}))
|
|
44
|
+
for sub in schema.get("allOf", []):
|
|
45
|
+
if isinstance(sub, dict):
|
|
46
|
+
more = _declared(root, sub, seen)
|
|
47
|
+
if more is None:
|
|
48
|
+
return None
|
|
49
|
+
names |= more
|
|
50
|
+
if schema.get("patternProperties"):
|
|
51
|
+
return None # required names may be matched by a pattern: cannot judge
|
|
52
|
+
return names
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _walk(root: Any, node: Any, where: str, out: list[str], depth: int) -> None:
|
|
56
|
+
if depth > MAX_DEPTH:
|
|
57
|
+
out.append(f"{where}: deeper than {MAX_DEPTH}")
|
|
58
|
+
return
|
|
59
|
+
if isinstance(node, list):
|
|
60
|
+
for i, item in enumerate(node):
|
|
61
|
+
_walk(root, item, f"{where}[{i}]", out, depth + 1)
|
|
62
|
+
return
|
|
63
|
+
if not isinstance(node, dict):
|
|
64
|
+
return
|
|
65
|
+
if "$ref" in node:
|
|
66
|
+
try:
|
|
67
|
+
_resolve(root, node["$ref"])
|
|
68
|
+
except (KeyError, ValueError) as exc:
|
|
69
|
+
out.append(f"{where}: unresolved $ref {exc}")
|
|
70
|
+
kind = node.get("type")
|
|
71
|
+
kinds = kind if isinstance(kind, list) else [kind] if kind else []
|
|
72
|
+
for k in kinds:
|
|
73
|
+
if k not in TYPES:
|
|
74
|
+
out.append(f"{where}: unknown type {k!r}")
|
|
75
|
+
if "properties" in node and not isinstance(node["properties"], dict):
|
|
76
|
+
out.append(f"{where}: properties is not an object")
|
|
77
|
+
required = node.get("required")
|
|
78
|
+
if required is not None:
|
|
79
|
+
if not isinstance(required, list) or not all(isinstance(r, str) for r in required):
|
|
80
|
+
out.append(f"{where}: required must be a list of names")
|
|
81
|
+
else:
|
|
82
|
+
declared = _declared(root, node) if isinstance(root, dict) else set()
|
|
83
|
+
if declared is not None:
|
|
84
|
+
missing = [r for r in required if r not in declared]
|
|
85
|
+
# A required name nobody declares is a contract bug, closed or open:
|
|
86
|
+
# clients build forms and calls from `properties`.
|
|
87
|
+
for name in missing:
|
|
88
|
+
out.append(f"{where}: required {name!r} not declared in properties")
|
|
89
|
+
for key, value in node.items():
|
|
90
|
+
if key == "properties" and isinstance(value, dict):
|
|
91
|
+
for name, sub in value.items():
|
|
92
|
+
_walk(root, sub, f"{where}.properties.{name}", out, depth + 1)
|
|
93
|
+
elif key in ("items", "additionalProperties", "not", "if", "then", "else", "contains") or key in (
|
|
94
|
+
"anyOf",
|
|
95
|
+
"oneOf",
|
|
96
|
+
"allOf",
|
|
97
|
+
"prefixItems",
|
|
98
|
+
):
|
|
99
|
+
_walk(root, value, f"{where}.{key}", out, depth + 1)
|
|
100
|
+
elif key in ("$defs", "definitions") and isinstance(value, dict):
|
|
101
|
+
for name, sub in value.items():
|
|
102
|
+
_walk(root, sub, f"{where}.{key}.{name}", out, depth + 1)
|
|
103
|
+
if "default" in node and kinds and not _matches(node["default"], kinds):
|
|
104
|
+
out.append(f"{where}: default {node['default']!r} is not {kinds}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _matches(value: Any, kinds: list[str]) -> bool:
|
|
108
|
+
checks = {
|
|
109
|
+
"string": lambda v: isinstance(v, str),
|
|
110
|
+
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
111
|
+
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
|
112
|
+
"boolean": lambda v: isinstance(v, bool),
|
|
113
|
+
"object": lambda v: isinstance(v, dict),
|
|
114
|
+
"array": lambda v: isinstance(v, list),
|
|
115
|
+
"null": lambda v: v is None,
|
|
116
|
+
}
|
|
117
|
+
return any(checks[k](value) for k in kinds if k in checks)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def tool_problems(tools: list[dict]) -> list[str]:
|
|
121
|
+
out: list[str] = []
|
|
122
|
+
names = [t.get("name") for t in tools]
|
|
123
|
+
for dup in {n for n in names if names.count(n) > 1}:
|
|
124
|
+
out.append(f"duplicate tool {dup}")
|
|
125
|
+
for tool in tools:
|
|
126
|
+
schema = tool.get("inputSchema")
|
|
127
|
+
if not isinstance(schema, dict) or schema.get("type") != "object":
|
|
128
|
+
out.append(f"{tool.get('name')}: inputSchema must be an object schema")
|
|
129
|
+
continue
|
|
130
|
+
out += problems(schema, tool.get("name", "?"))
|
|
131
|
+
return out
|
subtask/server.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""MCP server (stdio).
|
|
2
|
+
|
|
3
|
+
Instructions and tool descriptions are injected into every client session, so
|
|
4
|
+
they are kept short. The full usage guide is the `subtask` skill (subtask/skill/SKILL.md).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
13
|
+
|
|
14
|
+
from . import core
|
|
15
|
+
|
|
16
|
+
mcp = FastMCP(
|
|
17
|
+
"subtask",
|
|
18
|
+
instructions=(
|
|
19
|
+
"Background sub-agents for parallel, long or review work; small edits do yourself. "
|
|
20
|
+
"Pattern: ONE start_many([...]) with every job, then ONE wait(ids): it blocks and returns "
|
|
21
|
+
"the summaries. Never poll status(); call wait() again only if it said 'still running'. "
|
|
22
|
+
"result(id, full/evidence) only when the summary is not enough. "
|
|
23
|
+
"Call step() before each significant step."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@mcp.tool(structured_output=False)
|
|
29
|
+
def start(
|
|
30
|
+
task: str,
|
|
31
|
+
kind: str = "code",
|
|
32
|
+
repo: str | None = None,
|
|
33
|
+
cwd: str | None = None,
|
|
34
|
+
files: list[str] | None = None,
|
|
35
|
+
readonly: bool = False,
|
|
36
|
+
engine: str = "auto",
|
|
37
|
+
max_usd: float | None = None,
|
|
38
|
+
effort: str = "medium",
|
|
39
|
+
base: str = "HEAD",
|
|
40
|
+
max_turns: int = 60,
|
|
41
|
+
policy: str | None = None,
|
|
42
|
+
verify: str | None = None,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Start a job. kind: read|code|review|hard. repo: new worktree. policy: worktree|sandbox|isolated|untrusted. verify: check command."""
|
|
45
|
+
return core.start(
|
|
46
|
+
task, engine, repo, cwd, base, readonly, max_usd, max_turns, effort,
|
|
47
|
+
kind=kind, files=files, policy=policy, verify=verify,
|
|
48
|
+
) # fmt: skip
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@mcp.tool(structured_output=False)
|
|
52
|
+
def start_many(tasks: list[dict]) -> str:
|
|
53
|
+
"""Items take start()'s arguments."""
|
|
54
|
+
return core.start_many(tasks)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@mcp.tool(structured_output=False)
|
|
58
|
+
async def wait(
|
|
59
|
+
ids: list[str],
|
|
60
|
+
timeout: int = 300,
|
|
61
|
+
any: bool = False,
|
|
62
|
+
since: str | None = None,
|
|
63
|
+
ctx: Context | None = None,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Wait for jobs (any=True: first); returns summaries. since: cursor for changes."""
|
|
66
|
+
# Poll on the event loop with asyncio.sleep: no thread is held while waiting,
|
|
67
|
+
# so any number of waits never delays stop() or status().
|
|
68
|
+
loop = asyncio.get_running_loop()
|
|
69
|
+
reporter = None
|
|
70
|
+
pending: list = []
|
|
71
|
+
token = ctx.request_context.meta.progressToken if ctx and ctx.request_context.meta else None
|
|
72
|
+
if token is not None:
|
|
73
|
+
from .progress import Reporter
|
|
74
|
+
|
|
75
|
+
def send(value: float, message: str, total: float | None) -> None:
|
|
76
|
+
pending.append(ctx.report_progress(value, total, message)) # awaited before the reply
|
|
77
|
+
|
|
78
|
+
reporter = Reporter(send, [i for i in ids if core.known(i)])
|
|
79
|
+
deadline = loop.time() + max(1, min(timeout, 600))
|
|
80
|
+
while True:
|
|
81
|
+
if reporter:
|
|
82
|
+
reporter.poll()
|
|
83
|
+
while pending:
|
|
84
|
+
await pending.pop(0) # never after the result: progress stops at completion
|
|
85
|
+
out = core.wait(ids, 0, any, since=since)
|
|
86
|
+
if not out.startswith("still running") or loop.time() >= deadline:
|
|
87
|
+
return out
|
|
88
|
+
await asyncio.sleep(1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@mcp.tool(structured_output=False)
|
|
92
|
+
def status(id: str | None = None) -> str:
|
|
93
|
+
"""One line per job."""
|
|
94
|
+
return core.status(id)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool(structured_output=False)
|
|
98
|
+
def result(id: str, full: bool = False, evidence: bool = False) -> str:
|
|
99
|
+
"""Job summary; full=True for all; evidence=True for the JSON envelope."""
|
|
100
|
+
return core.result(id, full, evidence)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@mcp.tool(structured_output=False)
|
|
104
|
+
def reply(id: str, message: str) -> str:
|
|
105
|
+
"""Continue a finished job."""
|
|
106
|
+
return core.reply(id, message)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool(structured_output=False)
|
|
110
|
+
async def stop(id: str) -> str:
|
|
111
|
+
"""Stop a job."""
|
|
112
|
+
return await asyncio.to_thread(core.stop, id)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool(structured_output=False)
|
|
116
|
+
def cleanup(id: str) -> str:
|
|
117
|
+
"""Remove a job's worktree, keep branch."""
|
|
118
|
+
return core.cleanup(id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@mcp.tool(structured_output=False)
|
|
122
|
+
def engines() -> str:
|
|
123
|
+
"""Free quota and API spend."""
|
|
124
|
+
return core.engines() + "\n" + core.spend()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool(structured_output=False)
|
|
128
|
+
async def step(
|
|
129
|
+
title: str, path: str, how: str, decisions: list[str] | None = None, repo: str | None = None
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Before each significant step. Paste the reply verbatim; on STOP ask the user."""
|
|
132
|
+
return await asyncio.to_thread(core.step, title, path, how, decisions, repo)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
ANNOTATIONS = ("title",)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _slim(schema: object, names: bool = False) -> object:
|
|
139
|
+
"""Drop what the client never needs: annotation titles, and the null branch of optionals.
|
|
140
|
+
|
|
141
|
+
`names=True` means `schema` maps property names to subschemas (the value of
|
|
142
|
+
"properties", "$defs"...): its keys are names, never annotations, so a
|
|
143
|
+
parameter called `title` survives.
|
|
144
|
+
"""
|
|
145
|
+
if isinstance(schema, list):
|
|
146
|
+
return [_slim(item) for item in schema]
|
|
147
|
+
if not isinstance(schema, dict):
|
|
148
|
+
return schema
|
|
149
|
+
if names:
|
|
150
|
+
return {k: _slim(v) for k, v in schema.items()}
|
|
151
|
+
out = {
|
|
152
|
+
k: _slim(v, names=k in ("properties", "$defs", "definitions", "patternProperties"))
|
|
153
|
+
for k, v in schema.items()
|
|
154
|
+
if k not in ANNOTATIONS
|
|
155
|
+
}
|
|
156
|
+
branches = out.get("anyOf")
|
|
157
|
+
if isinstance(branches, list):
|
|
158
|
+
kept = [b for b in branches if b != {"type": "null"}]
|
|
159
|
+
if len(kept) == 1:
|
|
160
|
+
out.pop("anyOf")
|
|
161
|
+
out.update(kept[0])
|
|
162
|
+
if out.get("default", 0) is None:
|
|
163
|
+
out.pop("default")
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
for _tool in mcp._tool_manager.list_tools():
|
|
168
|
+
_tool.parameters = _slim(_tool.parameters)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def dump_tools() -> dict:
|
|
172
|
+
"""The canonical published spec; tools.json is generated from it (`--dump-tools`)."""
|
|
173
|
+
tools = asyncio.run(mcp.list_tools())
|
|
174
|
+
return {
|
|
175
|
+
"instructions": mcp.instructions,
|
|
176
|
+
"tools": [
|
|
177
|
+
{"name": t.name, "description": t.description, "inputSchema": t.inputSchema} for t in tools
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main() -> None:
|
|
183
|
+
import sys
|
|
184
|
+
|
|
185
|
+
if "--dump-tools" in sys.argv:
|
|
186
|
+
print(json.dumps(dump_tools(), ensure_ascii=False, indent=1))
|
|
187
|
+
return
|
|
188
|
+
mcp.run(transport="stdio")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
main()
|
subtask/skill/SKILL.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: subtask
|
|
3
|
+
description: Delegate work to background sub-agents through the subtask MCP tools (or the `subtask` CLI). Use when there are 2+ independent pieces to run in parallel, long or noisy work whose details you do not need, or complex code that needs an independent reviewer. Not for small edits.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# subtask: delegate cheaply
|
|
7
|
+
|
|
8
|
+
Every token you spend on delegation must be cheaper than doing the work
|
|
9
|
+
yourself. Follow these rules.
|
|
10
|
+
|
|
11
|
+
## Delegate or not
|
|
12
|
+
|
|
13
|
+
Delegate when one of these is true:
|
|
14
|
+
- 2 or more independent pieces can run at the same time;
|
|
15
|
+
- the work is long or noisy and you only need the outcome (broad search,
|
|
16
|
+
audit, refactor over many files, test-and-fix loop);
|
|
17
|
+
- complex code needs a reviewer who did not write it (concurrency, saved data
|
|
18
|
+
or migrations, security, money, logic shared by many callers).
|
|
19
|
+
|
|
20
|
+
Do NOT delegate when:
|
|
21
|
+
- the change is small (a few lines, one file) or you already know the fix;
|
|
22
|
+
- it needs what is in your context right now (a decision you just made with
|
|
23
|
+
the user, a half-read stack trace): the brief would be longer than the work;
|
|
24
|
+
- the next step depends on the answer within a minute: just do it;
|
|
25
|
+
- the pieces touch the same lines: run them one after the other yourself.
|
|
26
|
+
|
|
27
|
+
## The cheap call pattern
|
|
28
|
+
|
|
29
|
+
1. One call to start everything: `start_many([...])`, or `start(...)` for one job.
|
|
30
|
+
2. One call to collect: `wait(ids)`. It blocks until all jobs end and returns
|
|
31
|
+
each summary. Do not poll `status()` in a loop.
|
|
32
|
+
3. Only if a summary is not enough: `result(id, full=True)`; for checkable
|
|
33
|
+
facts (commits, changed files, test exit codes, cost):
|
|
34
|
+
`result(id, evidence=True)`.
|
|
35
|
+
4. To correct a finished job: `reply(id, "also handle X")`. It keeps the
|
|
36
|
+
sub-agent's context, so the brief is not sent again.
|
|
37
|
+
|
|
38
|
+
## Writing the brief
|
|
39
|
+
|
|
40
|
+
- State the goal, the done condition and how to verify it, in a few lines.
|
|
41
|
+
- Pass paths in `files=[...]`. Never paste file contents: the sub-agent reads them.
|
|
42
|
+
- Set `kind`: `read` (search, summarize: cheapest), `code`, `review`, `hard`.
|
|
43
|
+
- Set `readonly=True` for questions and audits. Use `repo=` for changes: the job
|
|
44
|
+
gets its own worktree and branch `subtask/<id>`, commits there, never pushes.
|
|
45
|
+
- Give `verify="pytest -q tests/test_x.py"` when there is a check: subtask runs
|
|
46
|
+
it itself on the commit the worker left, so "tests pass" is a fact, not a claim.
|
|
47
|
+
- `policy`: `worktree` (default, git isolation only, NOT a host sandbox),
|
|
48
|
+
`sandbox` (engine sandbox), `isolated` (bubblewrap: only the worktree is
|
|
49
|
+
writable, the home is hidden), `untrusted` (for repositories you do not trust).
|
|
50
|
+
A job that cannot get the policy is refused, never silently downgraded.
|
|
51
|
+
- Leave `engine="auto"`: it filters engines by policy, install, budget and
|
|
52
|
+
quota, then ranks them; the start reply says why it picked one.
|
|
53
|
+
|
|
54
|
+
## While jobs run
|
|
55
|
+
|
|
56
|
+
- Clients that show MCP progress display a live line during `wait`
|
|
57
|
+
(`Subtask · 01:16 · 2/3 ended — st-b2 running tests`). You do not need to
|
|
58
|
+
repeat it.
|
|
59
|
+
- If your client does not show progress, use `wait(ids, timeout=120, since="")`
|
|
60
|
+
and pass back the returned `cursor=` next time: each reply lists only what
|
|
61
|
+
changed. Tell the user one short line when something significant changed
|
|
62
|
+
(a job ended, failed, or is blocked); stay silent otherwise.
|
|
63
|
+
- Cancelling a `wait` does not stop the jobs; `stop(id)` does.
|
|
64
|
+
|
|
65
|
+
## After the job
|
|
66
|
+
|
|
67
|
+
- A summary is what the worker reports. Trust a `verified` status or the
|
|
68
|
+
evidence envelope over a sentence like "all tests pass".
|
|
69
|
+
- Review the branch diff yourself before merging: `git diff <base>..subtask/<id>`.
|
|
70
|
+
- Merge the exact commits you reviewed, then `cleanup(id)`.
|
|
71
|
+
- If something failed, decide: `reply()` once with a precise fix, or take it
|
|
72
|
+
over. Do not loop.
|
|
73
|
+
|
|
74
|
+
## Announce every significant step
|
|
75
|
+
|
|
76
|
+
Before each significant step (new phase, choice of approach, a change with
|
|
77
|
+
risk, a merge or release) call `step(title, path, how, decisions)` and show
|
|
78
|
+
the returned text to the user verbatim. A separate cheap model reads your
|
|
79
|
+
declaration, the repo state and the observed sub-task activity, and keeps
|
|
80
|
+
intention and facts apart. Small edits and reads are not steps.
|
|
81
|
+
|
|
82
|
+
## Example
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
start_many([
|
|
86
|
+
{"task": "Find every caller of parse_date() and list the formats each passes.",
|
|
87
|
+
"kind": "read", "readonly": true, "cwd": "/src/app"},
|
|
88
|
+
{"task": "Make tests/test_sync.py pass without changing its assertions.",
|
|
89
|
+
"kind": "code", "repo": "/src/app", "files": ["src/sync.py", "tests/test_sync.py"],
|
|
90
|
+
"verify": "pytest -q tests/test_sync.py"}
|
|
91
|
+
])
|
|
92
|
+
wait(["st-1a2b3c", "st-4d5e6f"])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
CLI equivalent: `subtask start "..." --kind read --readonly --cwd .`,
|
|
96
|
+
`subtask wait ID ID`, `subtask result ID --evidence`.
|
subtask/telemetry.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Local, versioned, redacted telemetry: one row per finished attempt.
|
|
2
|
+
|
|
3
|
+
<HOME>/telemetry/YYYY-MM.jsonl. Nothing leaves the machine. Rows older than
|
|
4
|
+
SUBTASK_TELEMETRY_DAYS (default 90) are dropped by `subtask gc`.
|
|
5
|
+
Every number says what it is: exact (observed from the engine or git),
|
|
6
|
+
estimated (e.g. tokens = bytes/4) or missing (None).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import config
|
|
17
|
+
from .redact import redact
|
|
18
|
+
|
|
19
|
+
VERSION = 1
|
|
20
|
+
RETENTION_DAYS = int(os.environ.get("SUBTASK_TELEMETRY_DAYS", "90"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _dir() -> Path:
|
|
24
|
+
return config.HOME / "telemetry"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def record(row: dict) -> None:
|
|
28
|
+
folder = _dir()
|
|
29
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
row = {"v": VERSION, "ts": time.time(), **row}
|
|
31
|
+
line = redact(json.dumps(row, ensure_ascii=False))
|
|
32
|
+
with (folder / f"{time.strftime('%Y-%m')}.jsonl").open("a", encoding="utf-8") as fh:
|
|
33
|
+
fh.write(line + "\n")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rows(months: int = 3) -> list[dict]:
|
|
37
|
+
folder = _dir()
|
|
38
|
+
if not folder.is_dir():
|
|
39
|
+
return []
|
|
40
|
+
out: list[dict] = []
|
|
41
|
+
for path in sorted(folder.glob("????-??.jsonl"))[-months:]:
|
|
42
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
43
|
+
try:
|
|
44
|
+
out.append(json.loads(line))
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
continue
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def count_call(job_ids: list[str], tool: str) -> None:
|
|
51
|
+
"""One parent round trip touching these jobs (counted by the servers and the CLI)."""
|
|
52
|
+
for job_id in job_ids:
|
|
53
|
+
path = config.JOBS / job_id / "calls.jsonl"
|
|
54
|
+
if path.parent.is_dir():
|
|
55
|
+
with path.open("a") as fh:
|
|
56
|
+
fh.write(json.dumps({"ts": time.time(), "tool": tool}) + "\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def calls(job_id: str) -> list[dict]:
|
|
60
|
+
path = config.JOBS / job_id / "calls.jsonl"
|
|
61
|
+
try:
|
|
62
|
+
return [json.loads(x) for x in path.read_text().splitlines() if x.strip()]
|
|
63
|
+
except (OSError, json.JSONDecodeError):
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def prune(days: int = RETENTION_DAYS, dry_run: bool = True) -> list[Path]:
|
|
68
|
+
folder = _dir()
|
|
69
|
+
if not folder.is_dir():
|
|
70
|
+
return []
|
|
71
|
+
cutoff = time.strftime("%Y-%m", time.gmtime(time.time() - days * 86400))
|
|
72
|
+
old = [p for p in folder.glob("????-??.jsonl") if p.stem < cutoff]
|
|
73
|
+
if not dry_run:
|
|
74
|
+
for path in old:
|
|
75
|
+
path.unlink()
|
|
76
|
+
return old
|