taskops-cli 0.2.0__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.
- taskops/__init__.py +39 -0
- taskops/_clock.py +32 -0
- taskops/_errors.py +160 -0
- taskops/_ids.py +63 -0
- taskops/_types.py +106 -0
- taskops/_version.py +7 -0
- taskops/assets/GUIDE.md +220 -0
- taskops/contracts/__init__.py +96 -0
- taskops/contracts/_fields.py +113 -0
- taskops/contracts/actor.py +30 -0
- taskops/contracts/board.py +142 -0
- taskops/contracts/commit.py +36 -0
- taskops/contracts/day.py +150 -0
- taskops/contracts/dep.py +23 -0
- taskops/contracts/event.py +58 -0
- taskops/contracts/gitstate.py +45 -0
- taskops/contracts/index.py +37 -0
- taskops/contracts/lease.py +43 -0
- taskops/contracts/log.py +59 -0
- taskops/contracts/remote.py +48 -0
- taskops/contracts/results.py +84 -0
- taskops/contracts/task.py +94 -0
- taskops/contracts/tools.py +110 -0
- taskops/contracts/wire.py +60 -0
- taskops/engine/__init__.py +34 -0
- taskops/engine/_blocks.py +48 -0
- taskops/engine/_briefs.py +92 -0
- taskops/engine/_chunks.py +66 -0
- taskops/engine/_closed.py +65 -0
- taskops/engine/_entries.py +126 -0
- taskops/engine/_events.py +55 -0
- taskops/engine/_opened.py +51 -0
- taskops/engine/_process.py +80 -0
- taskops/engine/_prompts.py +98 -0
- taskops/engine/_stream.py +129 -0
- taskops/engine/activity.py +94 -0
- taskops/engine/bus.py +42 -0
- taskops/engine/commitline.py +110 -0
- taskops/engine/day.py +142 -0
- taskops/engine/diffstat.py +63 -0
- taskops/engine/gitio.py +108 -0
- taskops/engine/gitstate.py +96 -0
- taskops/engine/history.py +76 -0
- taskops/engine/identity.py +84 -0
- taskops/engine/log.py +68 -0
- taskops/engine/machine.py +160 -0
- taskops/engine/narrate.py +91 -0
- taskops/engine/project.py +57 -0
- taskops/engine/replay.py +142 -0
- taskops/engine/reports.py +67 -0
- taskops/engine/scheduler.py +135 -0
- taskops/engine/transcript.py +127 -0
- taskops/engine/wire.py +92 -0
- taskops/engine/worker.py +115 -0
- taskops/py.typed +0 -0
- taskops/render/__init__.py +40 -0
- taskops/render/_closed_days.py +64 -0
- taskops/render/_dossier.py +68 -0
- taskops/render/_opened.py +64 -0
- taskops/render/_sections.py +51 -0
- taskops/render/_tasklist.py +72 -0
- taskops/render/_text.py +74 -0
- taskops/render/_verbatim.py +65 -0
- taskops/render/ansi.py +92 -0
- taskops/render/board.py +55 -0
- taskops/render/day.py +102 -0
- taskops/render/dispatch.py +104 -0
- taskops/render/inbox.py +27 -0
- taskops/render/log.py +47 -0
- taskops/render/recover.py +64 -0
- taskops/render/report.py +51 -0
- taskops/render/reports.py +57 -0
- taskops/render/results.py +96 -0
- taskops/render/session.py +75 -0
- taskops/render/task.py +88 -0
- taskops/render/tasklist.py +65 -0
- taskops/storage/__init__.py +36 -0
- taskops/storage/_ddl.py +78 -0
- taskops/storage/_delivered.py +51 -0
- taskops/storage/_deps.py +69 -0
- taskops/storage/_events.py +127 -0
- taskops/storage/_leases.py +108 -0
- taskops/storage/_rows.py +90 -0
- taskops/storage/_tasks.py +124 -0
- taskops/storage/locate.py +76 -0
- taskops/storage/schema.py +66 -0
- taskops/storage/store.py +120 -0
- taskops/storage/sync.py +139 -0
- taskops/transports/__init__.py +6 -0
- taskops/transports/cli/__init__.py +0 -0
- taskops/transports/cli/commands/__init__.py +3 -0
- taskops/transports/cli/commands/_digest.py +64 -0
- taskops/transports/cli/commands/_serve_init.py +65 -0
- taskops/transports/cli/commands/_shared.py +50 -0
- taskops/transports/cli/commands/_tasks_args.py +112 -0
- taskops/transports/cli/commands/_window.py +45 -0
- taskops/transports/cli/commands/ask.py +27 -0
- taskops/transports/cli/commands/dispatch.py +43 -0
- taskops/transports/cli/commands/init.py +48 -0
- taskops/transports/cli/commands/log.py +22 -0
- taskops/transports/cli/commands/plan.py +43 -0
- taskops/transports/cli/commands/pushpull.py +53 -0
- taskops/transports/cli/commands/recover.py +30 -0
- taskops/transports/cli/commands/remote.py +56 -0
- taskops/transports/cli/commands/report.py +82 -0
- taskops/transports/cli/commands/run_.py +60 -0
- taskops/transports/cli/commands/serve.py +74 -0
- taskops/transports/cli/commands/sync.py +22 -0
- taskops/transports/cli/commands/tasks.py +79 -0
- taskops/transports/cli/commands/ui.py +72 -0
- taskops/transports/cli/commands/update.py +25 -0
- taskops/transports/cli/main.py +85 -0
- taskops/transports/hooks/__init__.py +15 -0
- taskops/transports/hooks/__main__.py +63 -0
- taskops/transports/hooks/_args.py +28 -0
- taskops/transports/hooks/claude.py +78 -0
- taskops/transports/hooks/commit.py +61 -0
- taskops/transports/hooks/events.py +107 -0
- taskops/transports/hooks/record.py +55 -0
- taskops/transports/http/__init__.py +21 -0
- taskops/transports/http/_handler.py +132 -0
- taskops/transports/http/_wire.py +79 -0
- taskops/transports/http/_wsframes.py +116 -0
- taskops/transports/http/agentapi.py +69 -0
- taskops/transports/http/api.py +122 -0
- taskops/transports/http/exchange.py +80 -0
- taskops/transports/http/live.py +160 -0
- taskops/transports/http/policy.py +110 -0
- taskops/transports/http/projects.py +116 -0
- taskops/transports/http/reports.py +75 -0
- taskops/transports/http/router.py +90 -0
- taskops/transports/http/server.py +50 -0
- taskops/transports/http/static.py +86 -0
- taskops/transports/http/ui/app.js +59 -0
- taskops/transports/http/ui/index.html +23 -0
- taskops/transports/http/ui/style.css +1 -0
- taskops/transports/http/websocket.py +60 -0
- taskops/transports/mcp/__init__.py +19 -0
- taskops/transports/mcp/__main__.py +7 -0
- taskops/transports/mcp/_descriptions.py +102 -0
- taskops/transports/mcp/_reads.py +82 -0
- taskops/transports/mcp/_writes.py +72 -0
- taskops/transports/mcp/answers.py +44 -0
- taskops/transports/mcp/arguments.py +104 -0
- taskops/transports/mcp/dispatch.py +47 -0
- taskops/transports/mcp/protocol.py +83 -0
- taskops/transports/mcp/schema.py +50 -0
- taskops/transports/mcp/server.py +49 -0
- taskops/transports/mcp/tools.py +66 -0
- taskops/usecases/__init__.py +56 -0
- taskops/usecases/_entry.py +84 -0
- taskops/usecases/_facts.py +49 -0
- taskops/usecases/_freeing.py +93 -0
- taskops/usecases/_gitignore.py +94 -0
- taskops/usecases/_mirroring.py +57 -0
- taskops/usecases/_narrating.py +77 -0
- taskops/usecases/_project.py +67 -0
- taskops/usecases/_range.py +102 -0
- taskops/usecases/_reasons.py +54 -0
- taskops/usecases/_remotefile.py +62 -0
- taskops/usecases/_reportsync.py +108 -0
- taskops/usecases/_routing.py +91 -0
- taskops/usecases/_wireclient.py +141 -0
- taskops/usecases/ask.py +41 -0
- taskops/usecases/claim.py +104 -0
- taskops/usecases/dispatch.py +160 -0
- taskops/usecases/dossier.py +155 -0
- taskops/usecases/edit.py +71 -0
- taskops/usecases/exchange.py +86 -0
- taskops/usecases/feed.py +138 -0
- taskops/usecases/guard.py +122 -0
- taskops/usecases/hooks.py +127 -0
- taskops/usecases/index.py +62 -0
- taskops/usecases/ingest.py +60 -0
- taskops/usecases/log.py +134 -0
- taskops/usecases/narration.py +91 -0
- taskops/usecases/plan.py +128 -0
- taskops/usecases/pushpull.py +119 -0
- taskops/usecases/recover.py +84 -0
- taskops/usecases/remote.py +78 -0
- taskops/usecases/report.py +87 -0
- taskops/usecases/reportfile.py +106 -0
- taskops/usecases/session.py +134 -0
- taskops/usecases/setup.py +92 -0
- taskops/usecases/sync.py +78 -0
- taskops/usecases/update.py +123 -0
- taskops/usecases/view.py +112 -0
- taskops_cli-0.2.0.dist-info/METADATA +291 -0
- taskops_cli-0.2.0.dist-info/RECORD +193 -0
- taskops_cli-0.2.0.dist-info/WHEEL +5 -0
- taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
- taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- taskops_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Talking to a taskops server over HTTP, with the standard library and nothing else.
|
|
2
|
+
|
|
3
|
+
`pyproject` declares `dependencies = []` and that is a feature, not an oversight: taskops
|
|
4
|
+
installs into a repository whose owner did not ask for a dependency tree, and a sync client
|
|
5
|
+
is the classic place one gets added for the convenience of two lines. `urllib.request` is
|
|
6
|
+
those two lines.
|
|
7
|
+
|
|
8
|
+
**No retries.** A push that fails is re-run by the person who ran it, and the second run
|
|
9
|
+
costs nothing because every write here is idempotent — events are content-hashed, a report
|
|
10
|
+
PUT compares fingerprints. An automatic retry would therefore be *safe*, which is exactly
|
|
11
|
+
why it is a bad idea: it would hide a network that is failing behind a command that always
|
|
12
|
+
seems to work. One attempt, a 30-second timeout, and a sentence saying what did not answer.
|
|
13
|
+
|
|
14
|
+
That argument only got STRONGER when the agent calls arrived. `claim` and `change` are not
|
|
15
|
+
idempotent — a retried claim is a second claim, a retried `done` is a second transition — so a
|
|
16
|
+
retry here could turn a slow network into a card claimed twice. The failure is typed
|
|
17
|
+
(`Unreachable`) precisely so the caller can decide, and for a claim the only safe decision is
|
|
18
|
+
to stop.
|
|
19
|
+
|
|
20
|
+
**Server errors are relayed verbatim.** The server writes its `error` field for a person;
|
|
21
|
+
translating it into "HTTP 409" here would throw away the only text that says what to do.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
from typing import Any, cast
|
|
31
|
+
|
|
32
|
+
from .._errors import TaskopsError, Unreachable
|
|
33
|
+
|
|
34
|
+
__all__ = ["Wire", "TIMEOUT", "PAGE"]
|
|
35
|
+
|
|
36
|
+
TIMEOUT = 30.0
|
|
37
|
+
PAGE = 500
|
|
38
|
+
"""The batch size in both directions — the cap the server's contract declares."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Wire:
|
|
42
|
+
"""One remote, six calls — four for replication, two for the writes an agent makes.
|
|
43
|
+
Holds no state beyond where to reach it and how to prove who it is, so a caller may
|
|
44
|
+
build one per command and throw it away."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, url: str, token: str, *, timeout: float = TIMEOUT) -> None:
|
|
47
|
+
self.base = url.rstrip("/")
|
|
48
|
+
self.token = token
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
|
|
51
|
+
def post_events(self, events: list[Any]) -> dict[str, Any]:
|
|
52
|
+
"""`{"accepted": n, "max_seq": s}` — `accepted` counts the ones that were NEW there."""
|
|
53
|
+
return self.call("POST", "/api/sync", body={"events": events})
|
|
54
|
+
|
|
55
|
+
def get_events(self, after: int, limit: int = PAGE) -> dict[str, Any]:
|
|
56
|
+
"""One page from the server's log after a SERVER seq. `more` says to ask again."""
|
|
57
|
+
query = urllib.parse.urlencode({"after": after, "limit": limit})
|
|
58
|
+
return self.call("GET", f"/api/sync?{query}")
|
|
59
|
+
|
|
60
|
+
def get_report(self, label: str) -> dict[str, Any]:
|
|
61
|
+
"""The stored file and its `stamped_seq`. `status` is 404 when the server has none."""
|
|
62
|
+
query = urllib.parse.urlencode({"label": label})
|
|
63
|
+
return self.call("GET", f"/api/report/file?{query}", allow=(400, 404))
|
|
64
|
+
|
|
65
|
+
def list_reports(self) -> list[str]:
|
|
66
|
+
"""Every label the server holds — the labels-only read, degrading to nothing.
|
|
67
|
+
|
|
68
|
+
The contract froze `GET /api/report/file?label=…` before anyone noticed a client
|
|
69
|
+
cannot ASK for a report whose existence it has no way to learn. A listing was
|
|
70
|
+
proposed on the server's card as a purely additive shape; until it lands, a server
|
|
71
|
+
that answers 400/404 here is not an error — it means "reconcile what you know
|
|
72
|
+
about", which is every label already on this disk.
|
|
73
|
+
"""
|
|
74
|
+
answer = self.call("GET", "/api/report/file", allow=(400, 404))
|
|
75
|
+
found: Any = answer.get("labels")
|
|
76
|
+
if not isinstance(found, list):
|
|
77
|
+
return []
|
|
78
|
+
return [str(label) for label in cast("list[Any]", found)]
|
|
79
|
+
|
|
80
|
+
def put_report(self, label: str, content: str, *, force: bool = False) -> dict[str, Any]:
|
|
81
|
+
"""Store a report. `{"stored": true}`, or the 409 body with both seqs in it.
|
|
82
|
+
|
|
83
|
+
The conflict is RETURNED rather than raised because it is not an error at this
|
|
84
|
+
layer: reconciling twenty reports must not stop at the first disagreement, and the
|
|
85
|
+
caller's job is to collect them and tell the person which way each one goes.
|
|
86
|
+
"""
|
|
87
|
+
body = {"label": label, "content": content, "force": force}
|
|
88
|
+
return self.call("PUT", "/api/report/file", body=body, allow=(409,))
|
|
89
|
+
|
|
90
|
+
def claim(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
91
|
+
"""`POST /api/next` — a claim decided in the SERVER's sqlite, not in ours."""
|
|
92
|
+
return self.call("POST", "/api/next", body=body)
|
|
93
|
+
|
|
94
|
+
def change(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
95
|
+
"""`POST /api/update` — the transition, checked by the server's guards."""
|
|
96
|
+
return self.call("POST", "/api/update", body=body)
|
|
97
|
+
|
|
98
|
+
def call(self, method: str, path: str, *, body: Any = None,
|
|
99
|
+
allow: tuple[int, ...] = ()) -> dict[str, Any]:
|
|
100
|
+
"""One request. A status in `allow` comes back as data with `status` set."""
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(self._request(method, path, body),
|
|
103
|
+
timeout=self.timeout) as answer:
|
|
104
|
+
return _decode(answer.read())
|
|
105
|
+
except urllib.error.HTTPError as err:
|
|
106
|
+
payload = _decode(err.read())
|
|
107
|
+
if err.code in allow:
|
|
108
|
+
return {**payload, "status": err.code}
|
|
109
|
+
raise TaskopsError(_message(payload, self.base + path, err.code)) from err
|
|
110
|
+
except (urllib.error.URLError, OSError) as err:
|
|
111
|
+
raise Unreachable(
|
|
112
|
+
f"could not reach {self.base}: {_cause(err)} — the local board is still "
|
|
113
|
+
f"yours; run this again when the network is back") from err
|
|
114
|
+
|
|
115
|
+
def _request(self, method: str, path: str, body: Any) -> urllib.request.Request:
|
|
116
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
117
|
+
request = urllib.request.Request(self.base + path, data=data, method=method)
|
|
118
|
+
request.add_header("Authorization", f"Bearer {self.token}")
|
|
119
|
+
if data is not None:
|
|
120
|
+
request.add_header("Content-Type", "application/json")
|
|
121
|
+
return request
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _decode(raw: bytes) -> dict[str, Any]:
|
|
125
|
+
"""A JSON object, or an empty one. A body that is not JSON is not a crash: an nginx in
|
|
126
|
+
front of the server answers 502 in HTML, and the reader wants the status, not a
|
|
127
|
+
traceback through the parser."""
|
|
128
|
+
try:
|
|
129
|
+
parsed: Any = json.loads(raw.decode("utf-8", errors="replace") or "{}")
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
return {}
|
|
132
|
+
return cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _message(payload: dict[str, Any], where: str, code: int) -> str:
|
|
136
|
+
told = str(payload.get("error") or "").strip()
|
|
137
|
+
return told or f"{where} answered {code} and said nothing a person can act on"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cause(err: Exception) -> str:
|
|
141
|
+
return str(getattr(err, "reason", "") or err) or err.__class__.__name__
|
taskops/usecases/ask.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""`taskops_ask` — reading. One task in full, or a search when there is no id.
|
|
2
|
+
|
|
3
|
+
Two shapes behind one tool because they are one intent with one missing piece: an
|
|
4
|
+
agent either knows which task it means or it does not, and making that two tools would
|
|
5
|
+
have it choose wrong half the time. With `task` it returns the whole context; with
|
|
6
|
+
`query` it returns candidates, and the agent asks again by id.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .._errors import BadRequest
|
|
14
|
+
from ..contracts import Task, TaskView
|
|
15
|
+
from ._project import caller, heartbeat, project
|
|
16
|
+
from .view import view
|
|
17
|
+
|
|
18
|
+
__all__ = ["ask", "search"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ask(start: Path | str, task_id: str, *, actor: str = "") -> TaskView:
|
|
22
|
+
"""One task and everything around it."""
|
|
23
|
+
if not task_id.strip():
|
|
24
|
+
raise BadRequest("`task` is required — pass `query` instead to search")
|
|
25
|
+
with project(start) as store:
|
|
26
|
+
heartbeat(store, caller(store, actor)["id"])
|
|
27
|
+
return view(store, task_id.strip())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def search(start: Path | str, query: str, *, limit: int = 20) -> list[Task]:
|
|
31
|
+
"""Tasks whose title or spec contains `query`.
|
|
32
|
+
|
|
33
|
+
Comments are deliberately NOT searched. They are the largest text in the system
|
|
34
|
+
and the least specific: a substring match over a conversation returns the task
|
|
35
|
+
where somebody mentioned a word once, ranked identically to the task the word
|
|
36
|
+
names. When this needs to be better it needs ranking, not a wider LIKE.
|
|
37
|
+
"""
|
|
38
|
+
if not query.strip():
|
|
39
|
+
raise BadRequest("`query` is required — a non-empty string")
|
|
40
|
+
with project(start) as store:
|
|
41
|
+
return store.tasks.search(query.strip(), limit=limit)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""`taskops_next` — the call an agent makes to start working.
|
|
2
|
+
|
|
3
|
+
The loop matters: `ready_tasks` is ORDERED, and losing a race on the best task means
|
|
4
|
+
trying the next one rather than failing. With twenty agents asking at once, contention
|
|
5
|
+
is the normal case, and an implementation that returned an error on a lost race would
|
|
6
|
+
have every agent retrying the same task in lockstep.
|
|
7
|
+
|
|
8
|
+
What comes back is not an id. It is the task, its spec, its conversation, its
|
|
9
|
+
collisions, the branch to create and the agent's inbox — everything the next few
|
|
10
|
+
minutes need, assembled in the one call the agent was already making.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .._clock import now
|
|
18
|
+
from ..contracts import Claim, NextResult, Task
|
|
19
|
+
from ..engine import branch_for, counts, ready_tasks, record, unblock
|
|
20
|
+
from ..engine import claim as take_lease
|
|
21
|
+
from ..storage import Store
|
|
22
|
+
from ._project import caller, heartbeat, project
|
|
23
|
+
from ._reasons import why_nothing
|
|
24
|
+
from ._routing import claim_remotely, routed, whoami
|
|
25
|
+
from .view import inbox_for, view
|
|
26
|
+
|
|
27
|
+
__all__ = ["next_task"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def next_task(start: Path | str, *, actor: str = "", session: str = "",
|
|
31
|
+
labels: tuple[str, ...] = (), task: str = "",
|
|
32
|
+
local: bool = False) -> NextResult:
|
|
33
|
+
"""Claim the best available task, or explain why there is none.
|
|
34
|
+
|
|
35
|
+
`local` forces the claim into THIS sqlite. It exists for the server, which runs this
|
|
36
|
+
function to answer `POST /api/next` and would otherwise call itself if the store it
|
|
37
|
+
serves happened to carry a `remote.json` — see `_routing`.
|
|
38
|
+
"""
|
|
39
|
+
if (remote := routed(start, local)) is not None:
|
|
40
|
+
return claim_remotely(start, remote, {"actor": whoami(start, actor),
|
|
41
|
+
"session": session,
|
|
42
|
+
"labels": list(labels), "task": task})
|
|
43
|
+
with project(start) as store:
|
|
44
|
+
who = caller(store, actor)["id"]
|
|
45
|
+
# BEFORE any write. Two reasons, and the first one is a bug this ordering fixed:
|
|
46
|
+
# sqlite3 auto-opens a transaction on the first write, and `BEGIN IMMEDIATE` inside
|
|
47
|
+
# one raises. The second is why it belongs first anyway — the sweep, the unblock and
|
|
48
|
+
# the claim are one decision, so they must be one transaction. An unblock that
|
|
49
|
+
# committed separately would publish a ready task this call is about to claim.
|
|
50
|
+
store.claiming()
|
|
51
|
+
heartbeat(store, who)
|
|
52
|
+
unblock(store)
|
|
53
|
+
claimed = _take(store, who, session, labels, task)
|
|
54
|
+
if claimed is None:
|
|
55
|
+
return _result(store, None, why_nothing(store, labels, task, who))
|
|
56
|
+
return _result(store, claimed, "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _take(store: Store, who: str, session: str, labels: tuple[str, ...],
|
|
60
|
+
wanted: str) -> Claim | None:
|
|
61
|
+
"""Walk the ordered pool until a claim lands. None if every candidate was taken.
|
|
62
|
+
|
|
63
|
+
A task asked for BY ID must still be `ready` AND not assigned to somebody else. Both checks were
|
|
64
|
+
missing and both were found by RUNNING the thing, not by review:
|
|
65
|
+
|
|
66
|
+
- the dependency check, by following the usage guide: `next --task <id>` on a task whose
|
|
67
|
+
dependency was open handed it over, so the path where a human names the task was the one that
|
|
68
|
+
skipped the graph.
|
|
69
|
+
- the assignment check, by watching a dispatched fleet: a worker invented its own actor id and
|
|
70
|
+
claimed a card assigned to another worker. Filtering `ready_tasks` was not enough, because
|
|
71
|
+
asking by id never went through it.
|
|
72
|
+
"""
|
|
73
|
+
if wanted:
|
|
74
|
+
asked_for = store.tasks.need(wanted)
|
|
75
|
+
pool = [asked_for] if _claimable(asked_for, who) else []
|
|
76
|
+
else:
|
|
77
|
+
pool = ready_tasks(store, labels=labels, actor=who)
|
|
78
|
+
for candidate in pool:
|
|
79
|
+
lease = take_lease(store, candidate, actor=who, session=session)
|
|
80
|
+
if lease is None:
|
|
81
|
+
continue
|
|
82
|
+
store.tasks.set_status(candidate["id"], "claimed", when=now())
|
|
83
|
+
record(store, task=candidate["id"], actor=who, kind="claimed",
|
|
84
|
+
body={"session": session, "branch": branch_for(candidate)})
|
|
85
|
+
return Claim(view=view(store, candidate["id"]), lease=lease,
|
|
86
|
+
branch=branch_for(candidate),
|
|
87
|
+
inbox=inbox_for(store, who))
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _claimable(task: Task, who: str) -> bool:
|
|
92
|
+
"""Ready, and either unassigned or assigned to this caller."""
|
|
93
|
+
return task["status"] == "ready" and task["assignee"] in ("", who)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _result(store: Store, claim: Claim | None, reason: str) -> NextResult:
|
|
97
|
+
"""The answer, always carrying the queue's shape.
|
|
98
|
+
|
|
99
|
+
The counts ride along on SUCCESS too, not only on failure: an agent that just took
|
|
100
|
+
the last ready task and can see that knows to plan rather than come straight back.
|
|
101
|
+
"""
|
|
102
|
+
numbers = counts(store)
|
|
103
|
+
return NextResult(claim=claim, reason=reason, ready=numbers["ready"],
|
|
104
|
+
working=numbers["working"], blocked=numbers["blocked"])
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""`taskops_dispatch` — one agent launches others and hands each a card.
|
|
2
|
+
|
|
3
|
+
The flow this exists for, and the one taskops could not express before:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
planner ──▶ taskops_plan five cards, wired
|
|
7
|
+
──▶ taskops_dispatch five workers, one card each, running now
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Before this, a planner could create work and then nothing happened: cards sat in a pool waiting for a
|
|
11
|
+
human to open a terminal per agent. Assignment alone would not have fixed it either — an assigned card
|
|
12
|
+
with nobody running is still a card nobody is doing.
|
|
13
|
+
|
|
14
|
+
**It PREPARES; the orchestrator spawns.** By default this assigns the cards, makes their worktrees,
|
|
15
|
+
and hands back a ready-to-use brief per card — and starts no process at all. The caller then passes
|
|
16
|
+
each brief to its OWN sub-agent tool, so the workers run inside the session that is already open.
|
|
17
|
+
|
|
18
|
+
That default is a correction. The first version spawned `claude -p` per card, which opens a NEW
|
|
19
|
+
billed session each time; a real fleet of six drained an API balance mid-run and left six cards
|
|
20
|
+
claimed by processes that no longer existed. Sub-agents in the current session use the subscription
|
|
21
|
+
that is already paid for, and they die with the session instead of outliving it invisibly.
|
|
22
|
+
`spawn=True` still exists for the case somebody genuinely wants detached workers.
|
|
23
|
+
|
|
24
|
+
**Assign, then hand over, in that order.** The card is assigned to the worker's actor id BEFORE
|
|
25
|
+
anything can claim it, so the scheduler will only ever offer it to that worker — and no other agent
|
|
26
|
+
sees it at all.
|
|
27
|
+
|
|
28
|
+
**Concurrency is capped and the cap is low.** Each worker is a model doing unsupervised work in a
|
|
29
|
+
repository, so the default is conservative and the ceiling is explicit rather than "as many as you
|
|
30
|
+
asked for". A planner that wants twenty has to say twenty.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from .._clock import now
|
|
38
|
+
from .._errors import BadRequest
|
|
39
|
+
from ..contracts import Task
|
|
40
|
+
from ..engine import branch_for, record, unblock
|
|
41
|
+
from ..engine.worker import Launched, launch, prepare
|
|
42
|
+
from ..storage import Store
|
|
43
|
+
from ._project import caller, heartbeat, project
|
|
44
|
+
|
|
45
|
+
__all__ = ["dispatch", "DispatchResult", "MAX_WORKERS", "DEFAULT_WORKERS"]
|
|
46
|
+
|
|
47
|
+
DEFAULT_WORKERS = 3
|
|
48
|
+
"""How many launch when nobody says. Three is enough to see parallelism and few enough that a first
|
|
49
|
+
try on a real repository cannot turn into a stampede."""
|
|
50
|
+
|
|
51
|
+
MAX_WORKERS = 12
|
|
52
|
+
"""The ceiling, whatever was asked for. Every worker is a Claude Code process with a model behind it,
|
|
53
|
+
so the real limits are rate limits and money — and a planner that miscounts should hit a refusal here
|
|
54
|
+
rather than an invoice."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DispatchResult:
|
|
58
|
+
"""Who was launched, and who was not.
|
|
59
|
+
|
|
60
|
+
`skipped` is not an afterthought: a dispatch that quietly launched three of five would leave a
|
|
61
|
+
planner believing five agents are working, and the two cards it never started would look claimed
|
|
62
|
+
and never move.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, *, launched: list[Launched], skipped: list[str],
|
|
66
|
+
planned: bool = False, spawned: bool = False) -> None:
|
|
67
|
+
self.launched = launched
|
|
68
|
+
self.skipped = skipped
|
|
69
|
+
self.planned = planned
|
|
70
|
+
"""True for a dry run. The renderer says so loudly — a preview that reads like a result is
|
|
71
|
+
how somebody believes five agents are working when none are."""
|
|
72
|
+
|
|
73
|
+
self.spawned = spawned
|
|
74
|
+
"""True when processes were actually started. False in the default mode, where the cards are
|
|
75
|
+
assigned and briefed and the CALLER is expected to spawn sub-agents — so the renderer has to
|
|
76
|
+
tell it that the remaining half is its job."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def dispatch(start: Path | str, *, tasks: tuple[str, ...] = (), count: int = 0, actor: str = "",
|
|
80
|
+
prefix: str = "", model: str = "", dry_run: bool = False, spawn: bool = False,
|
|
81
|
+
use_api_key: bool = False) -> DispatchResult:
|
|
82
|
+
"""Launch a worker per card. Named cards, or the best `count` ready ones.
|
|
83
|
+
|
|
84
|
+
`count` rather than "everything ready" as the default shape, because "all" is the request nobody
|
|
85
|
+
means literally on a board with forty cards in it.
|
|
86
|
+
|
|
87
|
+
`dry_run` shows the plan and changes NOTHING — no assignment, no worktree, no process. It exists
|
|
88
|
+
because this is the one call in taskops that spends money: every worker is a model, and a planner
|
|
89
|
+
that miscounted should be able to look before it commits to five of them. It is also the honest
|
|
90
|
+
answer to "which cards would you pick", which no amount of reading the scheduler gives you.
|
|
91
|
+
|
|
92
|
+
`use_api_key` is the opt-in for billing per token: a spawned worker inherits the environment
|
|
93
|
+
MINUS `worker.DROPPED_ENV`, so it uses the logged-in subscription. Only the CLI passes it.
|
|
94
|
+
"""
|
|
95
|
+
with project(start) as store:
|
|
96
|
+
who = caller(store, actor)
|
|
97
|
+
heartbeat(store, who["id"])
|
|
98
|
+
unblock(store)
|
|
99
|
+
chosen, skipped = _choose(store, tasks, count, who["id"])
|
|
100
|
+
if dry_run:
|
|
101
|
+
return DispatchResult(launched=[_preview(store, t, who["dev"], prefix or "w", i)
|
|
102
|
+
for i, t in enumerate(chosen, start=1)],
|
|
103
|
+
skipped=skipped, planned=True)
|
|
104
|
+
prepared = [_assign(store, task, who["dev"], prefix or "w", i, spawn=spawn, model=model,
|
|
105
|
+
use_api_key=use_api_key) for i, task in enumerate(chosen, start=1)]
|
|
106
|
+
if not spawn:
|
|
107
|
+
return DispatchResult(launched=prepared, skipped=skipped, spawned=False)
|
|
108
|
+
return DispatchResult(launched=[w for w in prepared if w.pid],
|
|
109
|
+
skipped=skipped + [w.task for w in prepared if not w.pid],
|
|
110
|
+
spawned=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _preview(store: Store, task: Task, dev: str, prefix: str, index: int) -> Launched:
|
|
114
|
+
"""What a worker WOULD be. pid 0 says no process exists, which the renderer reads."""
|
|
115
|
+
from ..engine.worker import worktree_for
|
|
116
|
+
|
|
117
|
+
return Launched(actor=f"agent:{dev}/{prefix}{index}", task=task["id"], pid=0,
|
|
118
|
+
tree=worktree_for(store.root, task), log=Path(""),
|
|
119
|
+
branch=branch_for(task), brief="")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _choose(store: Store, wanted: tuple[str, ...], count: int,
|
|
123
|
+
asker: str) -> tuple[list[Task], list[str]]:
|
|
124
|
+
"""The cards to dispatch, and the ids that were refused with a reason attached to none.
|
|
125
|
+
|
|
126
|
+
A named card must be `ready` and unassigned — the same rule `next` enforces, for the same reason:
|
|
127
|
+
dispatching a worker onto a blocked card would start a process that immediately finds nothing to
|
|
128
|
+
do and exits, which reads as a broken dispatch rather than a wrong request.
|
|
129
|
+
"""
|
|
130
|
+
from ..engine import ready_tasks
|
|
131
|
+
|
|
132
|
+
if wanted:
|
|
133
|
+
found = [store.tasks.need(task_id) for task_id in wanted]
|
|
134
|
+
ok = [t for t in found if t["status"] == "ready" and not t["assignee"]]
|
|
135
|
+
return _capped(ok), [t["id"] for t in found if t not in ok]
|
|
136
|
+
return _capped(ready_tasks(store, actor=asker)[:count or DEFAULT_WORKERS]), []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _capped(tasks: list[Task]) -> list[Task]:
|
|
140
|
+
if len(tasks) > MAX_WORKERS:
|
|
141
|
+
raise BadRequest(f"{len(tasks)} workers asked for; the ceiling is {MAX_WORKERS} — "
|
|
142
|
+
f"dispatch in batches, or raise it deliberately")
|
|
143
|
+
return tasks
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _assign(store: Store, task: Task, dev: str, prefix: str, index: int, *,
|
|
147
|
+
spawn: bool, model: str, use_api_key: bool = False) -> Launched:
|
|
148
|
+
"""Assign the card and prepare its worktree; spawn a process only if asked.
|
|
149
|
+
|
|
150
|
+
Assignment FIRST, always — see the module docstring. The actor name is derived
|
|
151
|
+
(`agent:<dev>/<prefix><n>`) rather than random, so a fleet view reads as `berna/w1 … berna/w3`
|
|
152
|
+
instead of three hashes and a developer can tell at a glance which workers are theirs.
|
|
153
|
+
"""
|
|
154
|
+
worker = f"agent:{dev}/{prefix}{index}"
|
|
155
|
+
store.tasks.set_assignee(task["id"], worker, when=now())
|
|
156
|
+
record(store, task=task["id"], actor=worker, kind="handoff",
|
|
157
|
+
body={"assigned_to": worker, "dispatched": True, "mentions": [worker]})
|
|
158
|
+
if spawn:
|
|
159
|
+
return launch(store.root, task, actor=worker, model=model, use_api_key=use_api_key)
|
|
160
|
+
return prepare(store.root, task, actor=worker)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""`report … --write` — the dossier persisted where git can keep it.
|
|
2
|
+
|
|
3
|
+
The only use case here that writes a FILE rather than an event, and deliberately so. Every
|
|
4
|
+
other projection is regenerated on demand because regenerating is always right; this one
|
|
5
|
+
stops being derived the moment a human or `/taskops:digest` writes the narration into it, so
|
|
6
|
+
it is written once, refused a second time, and committed like source.
|
|
7
|
+
|
|
8
|
+
The window is a `Selector`, not a date: a day, a week and the whole project are the same
|
|
9
|
+
document over different spans, and the file is named by the window's LABEL — `2026-07-28.md`,
|
|
10
|
+
`2026-07-22..2026-07-28.md`, `all.md`.
|
|
11
|
+
|
|
12
|
+
Which is also why it goes through `render`: the file's shape is a rendering, and the same
|
|
13
|
+
string has to be producible in a test from a literal dict.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .._clock import now
|
|
21
|
+
from .._errors import AlreadyWritten
|
|
22
|
+
from ..contracts import ReportFile
|
|
23
|
+
from ..engine import (
|
|
24
|
+
OnPass,
|
|
25
|
+
OnText,
|
|
26
|
+
label_of,
|
|
27
|
+
missing_events,
|
|
28
|
+
narrate,
|
|
29
|
+
period_report,
|
|
30
|
+
stamp,
|
|
31
|
+
stamped_seq,
|
|
32
|
+
)
|
|
33
|
+
from ..render import is_pending, render_day, render_report
|
|
34
|
+
from ..storage import REPORTS_DIR, Store
|
|
35
|
+
from ._narrating import Progressive
|
|
36
|
+
from ._project import project
|
|
37
|
+
from ._range import Selector, is_day, resolve
|
|
38
|
+
|
|
39
|
+
__all__ = ["write_report", "read_report", "digest", "report_path"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_report(start: Path | str, sel: Selector | None = None, *,
|
|
43
|
+
force: bool = False) -> Path:
|
|
44
|
+
"""Render the window and write `.taskops/reports/<label>.md`. Returns where it landed.
|
|
45
|
+
|
|
46
|
+
Refuses an existing file unless `force`, because the file may already carry a narration
|
|
47
|
+
nobody can regenerate — and the report someone linked to yesterday changing under them is
|
|
48
|
+
exactly the thing that makes a written record worthless.
|
|
49
|
+
"""
|
|
50
|
+
with project(start) as store:
|
|
51
|
+
span = resolve(store, sel or Selector())
|
|
52
|
+
path = report_path(store.root, _label(span))
|
|
53
|
+
if path.exists() and not force:
|
|
54
|
+
raise AlreadyWritten(f"{path} already exists — read it, or pass --force to "
|
|
55
|
+
f"regenerate it (any narration in it is lost)")
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(_generate(store, span), encoding="utf-8")
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_report(start: Path | str, date_text: str = "") -> ReportFile:
|
|
62
|
+
"""What is on disk for a report LABEL — a day, a range, or `all`.
|
|
63
|
+
|
|
64
|
+
A day answers staleness (its window is fixed, so "did anything land after this was
|
|
65
|
+
written" is a fair question). Any other label answers `stale: False` on purpose: `all.md`
|
|
66
|
+
is out of date the moment anything happens, and a permanent red badge is noise, not
|
|
67
|
+
information. This was a real 400 in the UI — clicking `all.md` sent its label through the
|
|
68
|
+
day parser, which refused the very file the index had just listed.
|
|
69
|
+
|
|
70
|
+
A day with no file still answers with a dossier — the one that would be written — so a
|
|
71
|
+
reader never gets an empty screen and a "generate it" button as the only content.
|
|
72
|
+
"""
|
|
73
|
+
with project(start) as store:
|
|
74
|
+
label = date_text.strip()
|
|
75
|
+
path = report_path(store.root, label) if label else None
|
|
76
|
+
if path is not None and path.is_file() and not is_day(label):
|
|
77
|
+
written = path.read_text(encoding="utf-8")
|
|
78
|
+
return ReportFile(date=label, path=str(path), dossier_md=written,
|
|
79
|
+
exists=True, stale=False, missing_events=0)
|
|
80
|
+
span = resolve(store, Selector(date=date_text))
|
|
81
|
+
date = span[0]
|
|
82
|
+
path = report_path(store.root, date)
|
|
83
|
+
written = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
84
|
+
behind = missing_events(store, date, date, stamped_seq(written))
|
|
85
|
+
return ReportFile(date=date, path=str(path),
|
|
86
|
+
dossier_md=written or _generate(store, span),
|
|
87
|
+
exists=bool(written), stale=behind > 0, missing_events=behind)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def digest(start: Path | str, sel: Selector | None = None, *, model: str = "",
|
|
92
|
+
force: bool = False, on_pass: OnPass = None, on_text: OnText = None) -> Path:
|
|
93
|
+
"""Write the window's report if it is missing, then have Claude narrate it.
|
|
94
|
+
|
|
95
|
+
The two callbacks are how a caller SEES it happen — which pass is running, and the prose as
|
|
96
|
+
it is written. Optional, and nothing here prints: a use case that wrote to stdout would be
|
|
97
|
+
unusable from the HTTP transport, which calls this same function.
|
|
98
|
+
|
|
99
|
+
Two failures are kept apart on purpose. The dossier is written FIRST and committed to disk
|
|
100
|
+
before the model is called, so a narration that fails — no `claude`, not logged in, a
|
|
101
|
+
timeout — costs the prose and never the facts. Running it again then narrates the file that
|
|
102
|
+
is already there rather than starting over.
|
|
103
|
+
|
|
104
|
+
An existing narration is refused without `force` for the same reason a written report is:
|
|
105
|
+
somebody may have written it by hand, and this is the one section a machine cannot recover.
|
|
106
|
+
|
|
107
|
+
`force` regenerates the DOSSIER too, not only the prose. Narrating the file as it lies is
|
|
108
|
+
what the first version did, and it produced the worst possible failure: a fresh narration of
|
|
109
|
+
a stale document. A report written before a card was closed — or before the renderer learned
|
|
110
|
+
to show open cards at all — reads to the model as a day on which nothing happened, and the
|
|
111
|
+
prose says so, accurately and uselessly. Anybody passing `--force` is asking for this report
|
|
112
|
+
to be redone; redoing half of it is the answer nobody wants.
|
|
113
|
+
|
|
114
|
+
The prose reaches the file AS IT IS WRITTEN (`_narrating.Progressive`), not at the end. A
|
|
115
|
+
run of several passes used to leave `_pendiente_` on disk for a quarter of an hour, which
|
|
116
|
+
is indistinguishable from a report nobody narrated — and a crash at minute fourteen made
|
|
117
|
+
that permanent.
|
|
118
|
+
"""
|
|
119
|
+
with project(start) as store:
|
|
120
|
+
span = resolve(store, sel or Selector())
|
|
121
|
+
path = report_path(store.root, _label(span))
|
|
122
|
+
if force or not path.is_file():
|
|
123
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
path.write_text(_generate(store, span), encoding="utf-8")
|
|
125
|
+
report = path.read_text(encoding="utf-8")
|
|
126
|
+
if not is_pending(report) and not force:
|
|
127
|
+
raise AlreadyWritten(f"{path} already carries a narration — read it, or pass "
|
|
128
|
+
f"--force to replace it (the old one is lost)")
|
|
129
|
+
live = Progressive(path, report, on_pass=on_pass, on_text=on_text)
|
|
130
|
+
return live.finish(narrate(report, model=model, on_pass=live.passed, on_text=live.text))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def report_path(root: Path, label: str) -> Path:
|
|
134
|
+
return root / REPORTS_DIR / f"{label}.md"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _label(span: tuple[str, str, str]) -> str:
|
|
138
|
+
"""The window's name, asked of the renderer's own source of truth rather than rebuilt
|
|
139
|
+
here — a path and a heading that disagree is the bug this indirection exists to stop."""
|
|
140
|
+
return span[2] or label_of(span[0], span[1])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _generate(store: Store, span: tuple[str, str, str]) -> str:
|
|
144
|
+
"""The file's whole text, stamped with the log's max_seq AT THIS MOMENT.
|
|
145
|
+
|
|
146
|
+
Read after the dossier would be a race with itself — an event landing mid-render would be
|
|
147
|
+
in the report and outside its own fingerprint, and the file would claim to be older than
|
|
148
|
+
it is. Read first, and the worst case is a report that reports itself stale.
|
|
149
|
+
|
|
150
|
+
The rendering is `full`: the spec of every card, every comment whole, every file of every
|
|
151
|
+
commit. The terminal keeps the short form — this is the copy somebody reads INSTEAD of the
|
|
152
|
+
git log a month later, and a truncation it cannot expand is a fact permanently lost.
|
|
153
|
+
"""
|
|
154
|
+
return render_report(stamp(_label(span), store.events.max_seq(), now()),
|
|
155
|
+
render_day(period_report(store, *span), detail="full"))
|