taskproof 0.3.0__tar.gz → 0.4.1__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.
- {taskproof-0.3.0 → taskproof-0.4.1}/PKG-INFO +15 -8
- {taskproof-0.3.0 → taskproof-0.4.1}/README.md +13 -7
- {taskproof-0.3.0 → taskproof-0.4.1}/pyproject.toml +1 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/__init__.py +1 -1
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/cli.py +22 -3
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/dispatch.py +174 -22
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/errors.py +17 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/models.py +7 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/registry.py +101 -1
- taskproof-0.3.0/src/taskproof/web/assets/MatrixView-BMsawnxh.js → taskproof-0.4.1/src/taskproof/web/assets/MatrixView-CJ8fWUmX.js +61 -25
- taskproof-0.4.1/src/taskproof/web/assets/MatrixView-CJ8fWUmX.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/MatrixView-BM8nu9s2.css → taskproof-0.4.1/src/taskproof/web/assets/MatrixView-Dgb-l684.css +39 -39
- taskproof-0.4.1/src/taskproof/web/assets/MatrixView-Dgb-l684.css.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/ProjectsView-BcXcx5Gw.js → taskproof-0.4.1/src/taskproof/web/assets/ProjectsView-Co9z_MiY.js +2 -2
- taskproof-0.4.1/src/taskproof/web/assets/ProjectsView-Co9z_MiY.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/SettingsView-D_-waf0s.js → taskproof-0.4.1/src/taskproof/web/assets/SettingsView-BfOnYGw6.js +1 -1
- taskproof-0.4.1/src/taskproof/web/assets/SettingsView-BfOnYGw6.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/TaskDrawer-6JoGTH0s.js → taskproof-0.4.1/src/taskproof/web/assets/TaskDrawer-CUtASIW0.js +1 -1
- taskproof-0.4.1/src/taskproof/web/assets/TaskDrawer-CUtASIW0.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/TasksView-BWhSHCHi.js → taskproof-0.4.1/src/taskproof/web/assets/TasksView-B4jcl2uP.js +2 -2
- taskproof-0.4.1/src/taskproof/web/assets/TasksView-B4jcl2uP.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/index-NKj7kbYl.css → taskproof-0.4.1/src/taskproof/web/assets/index-CKcNecNO.css +42 -42
- taskproof-0.3.0/src/taskproof/web/assets/index-NKj7kbYl.css.gz → taskproof-0.4.1/src/taskproof/web/assets/index-CKcNecNO.css.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/index-B-PLdDbR.js → taskproof-0.4.1/src/taskproof/web/assets/index-DV5k90pQ.js +76 -34
- taskproof-0.4.1/src/taskproof/web/assets/index-DV5k90pQ.js.gz +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/web/index.html +2 -2
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof.egg-info/PKG-INFO +15 -8
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof.egg-info/SOURCES.txt +17 -16
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_accept_rerun.py +111 -7
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_api.py +5 -4
- taskproof-0.4.1/tests/test_check_card.py +232 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_cli.py +142 -1
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_dispatch.py +260 -1
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_ledger.py +26 -9
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_registry.py +236 -0
- taskproof-0.3.0/src/taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof-0.3.0/src/taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/LICENSE +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/setup.cfg +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/__main__.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/adapters/__init__.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/adapters/base.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/adapters/claude.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/adapters/codex.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/adapters/custom.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/adapters/gemini.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/adapters/opencode.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/api/__init__.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/api/server.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/concurrency.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/ledger.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/schemas/result.json +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/sql/schema.sql +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/storage.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/verify.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/web/assets/ProjectsView-CqNFL-fi.css +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/web/assets/SettingsView-aqTS3ubB.css +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/web/assets/TaskDrawer-N299VKzd.css +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/web/assets/TasksView-Bn08731V.css +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof.egg-info/dependency_links.txt +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof.egg-info/entry_points.txt +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/src/taskproof.egg-info/top_level.txt +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_adapters.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_adapters_parse.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_api_cors.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_api_log.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_board_cli.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_board_serve.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_concurrency.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_concurrency_race.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_control_surface.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_project_view.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_registry_write.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_result_schema.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_scan_assets.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_scope.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_storage.py +0 -0
- {taskproof-0.3.0 → taskproof-0.4.1}/tests/test_verify.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: A conveyor belt for AI coding agents: dispatch, independently verify, record.
|
|
5
5
|
Author-email: HaoHaoP <HaoHaoP@HaoHaoP.com>
|
|
6
6
|
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/HaoHaoP/taskproof
|
|
8
|
+
Project-URL: Documentation, https://taskproof.haohaop.com/
|
|
8
9
|
Project-URL: Issues, https://github.com/HaoHaoP/taskproof/issues
|
|
9
10
|
Keywords: ai-agents,orchestration,verification,codex,claude-code,cli
|
|
10
11
|
Classifier: Development Status :: 2 - Pre-Alpha
|
|
@@ -70,28 +71,34 @@ else keeps working.
|
|
|
70
71
|
|
|
71
72
|
### Command line
|
|
72
73
|
|
|
74
|
+
```bash
|
|
75
|
+
pipx install taskproof
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`uv tool install taskproof` does the same thing. Python **3.11+**, and the package
|
|
79
|
+
is pure standard-library Python — there is no dependency tree to resolve.
|
|
80
|
+
|
|
81
|
+
The latest development version, straight from the repository:
|
|
82
|
+
|
|
73
83
|
```bash
|
|
74
84
|
pipx install git+https://github.com/HaoHaoP/taskproof
|
|
75
85
|
```
|
|
76
86
|
|
|
77
|
-
|
|
87
|
+
Offline, or pinned to one build: download the wheel (or the sdist) from the
|
|
88
|
+
[Releases](https://github.com/HaoHaoP/taskproof/releases) page and install the
|
|
89
|
+
file. Its name carries the version, so take it from the release you downloaded —
|
|
90
|
+
there is no unpinned wheel name to copy.
|
|
78
91
|
|
|
79
92
|
```bash
|
|
80
93
|
pipx install ./taskproof-<version>-py3-none-any.whl
|
|
81
94
|
```
|
|
82
95
|
|
|
83
|
-
The wheel's filename carries the version — take it from the Release you just
|
|
84
|
-
downloaded; there is no unpinned wheel name to copy.
|
|
85
|
-
|
|
86
96
|
Or run from a source checkout:
|
|
87
97
|
|
|
88
98
|
```bash
|
|
89
99
|
pip install -e .
|
|
90
100
|
```
|
|
91
101
|
|
|
92
|
-
**There is no `taskproof` package on PyPI.** Do not look for it there; use one of
|
|
93
|
-
the three installs above.
|
|
94
|
-
|
|
95
102
|
## Run a task
|
|
96
103
|
|
|
97
104
|
```bash
|
|
@@ -50,28 +50,34 @@ else keeps working.
|
|
|
50
50
|
|
|
51
51
|
### Command line
|
|
52
52
|
|
|
53
|
+
```bash
|
|
54
|
+
pipx install taskproof
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`uv tool install taskproof` does the same thing. Python **3.11+**, and the package
|
|
58
|
+
is pure standard-library Python — there is no dependency tree to resolve.
|
|
59
|
+
|
|
60
|
+
The latest development version, straight from the repository:
|
|
61
|
+
|
|
53
62
|
```bash
|
|
54
63
|
pipx install git+https://github.com/HaoHaoP/taskproof
|
|
55
64
|
```
|
|
56
65
|
|
|
57
|
-
|
|
66
|
+
Offline, or pinned to one build: download the wheel (or the sdist) from the
|
|
67
|
+
[Releases](https://github.com/HaoHaoP/taskproof/releases) page and install the
|
|
68
|
+
file. Its name carries the version, so take it from the release you downloaded —
|
|
69
|
+
there is no unpinned wheel name to copy.
|
|
58
70
|
|
|
59
71
|
```bash
|
|
60
72
|
pipx install ./taskproof-<version>-py3-none-any.whl
|
|
61
73
|
```
|
|
62
74
|
|
|
63
|
-
The wheel's filename carries the version — take it from the Release you just
|
|
64
|
-
downloaded; there is no unpinned wheel name to copy.
|
|
65
|
-
|
|
66
75
|
Or run from a source checkout:
|
|
67
76
|
|
|
68
77
|
```bash
|
|
69
78
|
pip install -e .
|
|
70
79
|
```
|
|
71
80
|
|
|
72
|
-
**There is no `taskproof` package on PyPI.** Do not look for it there; use one of
|
|
73
|
-
the three installs above.
|
|
74
|
-
|
|
75
81
|
## Run a task
|
|
76
82
|
|
|
77
83
|
```bash
|
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
|
|
9
9
|
# The single source of the version: `pyproject.toml` reads it from here
|
|
10
10
|
# (`[tool.setuptools.dynamic]`), so cutting a release bumps exactly one string.
|
|
11
|
-
__version__ = "0.
|
|
11
|
+
__version__ = "0.4.1"
|
|
12
12
|
|
|
13
13
|
# Fail early and in plain language. Without this, running from a source checkout
|
|
14
14
|
# under an older interpreter surfaces as `ModuleNotFoundError: No module named
|
|
@@ -159,15 +159,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
159
159
|
p = sub.add_parser(
|
|
160
160
|
"accept",
|
|
161
161
|
help=(
|
|
162
|
-
"clear a blocked or
|
|
163
|
-
"
|
|
162
|
+
"clear a blocked, failed or timed-out task: done if acceptance passed "
|
|
163
|
+
"(failed and timeout cards need --note; a timeout stays timeout)"
|
|
164
164
|
),
|
|
165
165
|
)
|
|
166
166
|
p.add_argument("task_id")
|
|
167
167
|
p.add_argument(
|
|
168
168
|
"--note",
|
|
169
169
|
help=(
|
|
170
|
-
"why a failed card is being cleared; required for failed "
|
|
170
|
+
"why a failed or timed-out card is being cleared; required for failed "
|
|
171
|
+
"(false red) and timeout (keeping half-finished work) "
|
|
171
172
|
"(人工收尾必须留说明)"
|
|
172
173
|
),
|
|
173
174
|
)
|
|
@@ -375,6 +376,22 @@ def _read_probe_flags(reg_path) -> dict:
|
|
|
375
376
|
return registry.read_probe_flags(reg_path)
|
|
376
377
|
|
|
377
378
|
|
|
379
|
+
def _warn_unknown_keys(reg) -> None:
|
|
380
|
+
"""Print one stderr warning per key a block declares that nothing reads.
|
|
381
|
+
|
|
382
|
+
Card 88. An unknown key is not an error — the registry stays loadable and
|
|
383
|
+
the exit code stays 0 — but it is never silent either: this is how a
|
|
384
|
+
declaration that does nothing announces itself instead of waiting for a
|
|
385
|
+
human to notice months later (card 81's 22 dangling `timeout`s).
|
|
386
|
+
|
|
387
|
+
Only the two *listing* commands warn, and only on stderr, so `--json` keeps
|
|
388
|
+
the exact shape it had and `run` keeps its machine-readable stream clean.
|
|
389
|
+
"""
|
|
390
|
+
for block, key in reg.unknown_keys:
|
|
391
|
+
where = "[defaults]" if block == "[defaults]" else f"lane '{block}'"
|
|
392
|
+
print(f"warning: unknown key '{key}' in {where}", file=sys.stderr)
|
|
393
|
+
|
|
394
|
+
|
|
378
395
|
def _lane_overview(reg, stats, probes):
|
|
379
396
|
"""Project rows: one per project, each carrying its lanes and totals."""
|
|
380
397
|
projects = []
|
|
@@ -465,6 +482,7 @@ def cmd_projects(args):
|
|
|
465
482
|
"""
|
|
466
483
|
reg_path = registry.workspace_registry_path(args.workspace)
|
|
467
484
|
reg = registry.load(reg_path)
|
|
485
|
+
_warn_unknown_keys(reg)
|
|
468
486
|
probes = _read_probe_flags(reg_path)
|
|
469
487
|
|
|
470
488
|
overview = []
|
|
@@ -511,6 +529,7 @@ def cmd_taskgroups(args):
|
|
|
511
529
|
"""List every taskgroup (lane): id, owning project, path and verify command."""
|
|
512
530
|
reg_path = registry.workspace_registry_path(args.workspace)
|
|
513
531
|
reg = registry.load(reg_path)
|
|
532
|
+
_warn_unknown_keys(reg)
|
|
514
533
|
probes = _read_probe_flags(reg_path)
|
|
515
534
|
|
|
516
535
|
taskgroups = [registry.taskgroup_record(tg, probes) for tg in reg.taskgroups]
|
|
@@ -33,7 +33,14 @@ from typing import Optional
|
|
|
33
33
|
from . import concurrency, registry, storage, verify
|
|
34
34
|
from .adapters import get as get_adapter
|
|
35
35
|
from .adapters.base import ResultParseError
|
|
36
|
-
from .errors import
|
|
36
|
+
from .errors import (
|
|
37
|
+
EXIT_VERIFY,
|
|
38
|
+
AdapterError,
|
|
39
|
+
ConcurrencyError,
|
|
40
|
+
PreconditionError,
|
|
41
|
+
UsageError,
|
|
42
|
+
VerifyError,
|
|
43
|
+
)
|
|
37
44
|
from .models import (
|
|
38
45
|
STATUS_BLOCKED,
|
|
39
46
|
STATUS_CANCELLED,
|
|
@@ -77,6 +84,13 @@ _ALL_STATUSES = (
|
|
|
77
84
|
VERIFY_SKIPPED = "SKIPPED"
|
|
78
85
|
|
|
79
86
|
|
|
87
|
+
#: Who ran a `verify` event: the dispatch pipeline's step ⑦ does it as the card
|
|
88
|
+
#: winds down, a human re-checks afterwards (`taskproof verify <id>`). Recorded
|
|
89
|
+
#: as a fact the ledger can read back, so the two cases stop looking alike.
|
|
90
|
+
VERIFY_BY_RUN = "run"
|
|
91
|
+
VERIFY_BY_CLI = "cli"
|
|
92
|
+
|
|
93
|
+
|
|
80
94
|
#: Ceiling on the number of paths a single `changes` event carries. The audit
|
|
81
95
|
#: stream is read by humans (`show`) as well as machines, so a card that sweeps
|
|
82
96
|
#: thousands of files must not bloat the payload. The cut is not silent: the
|
|
@@ -159,6 +173,97 @@ def _cap_aware_refusal(exc: ConcurrencyError, setting) -> ConcurrencyError:
|
|
|
159
173
|
return exc
|
|
160
174
|
|
|
161
175
|
|
|
176
|
+
def _head_branch(path: str) -> str:
|
|
177
|
+
"""``git -C <path> rev-parse --abbrev-ref HEAD``; empty on any failure.
|
|
178
|
+
|
|
179
|
+
Read-only. A detached HEAD reports the literal ``HEAD`` — that is exactly
|
|
180
|
+
what the refusal message has to show, because a lane that wants a branch and
|
|
181
|
+
finds a detached checkout is looking at the same mistake as one that wants
|
|
182
|
+
`main` and finds `other`.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
proc = subprocess.run(
|
|
186
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
187
|
+
cwd=path,
|
|
188
|
+
stdout=subprocess.PIPE,
|
|
189
|
+
stderr=subprocess.DEVNULL,
|
|
190
|
+
text=True,
|
|
191
|
+
)
|
|
192
|
+
except (OSError, subprocess.SubprocessError):
|
|
193
|
+
return ""
|
|
194
|
+
if proc.returncode != 0:
|
|
195
|
+
return ""
|
|
196
|
+
return proc.stdout.strip()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _has_branch_commit(path: str, branch: str) -> bool:
|
|
200
|
+
"""Does ``refs/heads/<branch>`` resolve to a commit in ``path``?
|
|
201
|
+
|
|
202
|
+
Read-only, and deliberately the same question `git worktree add <branch>`
|
|
203
|
+
will ask a moment later: a name that no commit backs would otherwise turn
|
|
204
|
+
into a failed workspace creation AFTER a slot has been taken.
|
|
205
|
+
"""
|
|
206
|
+
revision = f"refs/heads/{branch}^{{commit}}"
|
|
207
|
+
try:
|
|
208
|
+
proc = subprocess.run(
|
|
209
|
+
["git", "rev-parse", "--verify", "--quiet", revision],
|
|
210
|
+
cwd=path,
|
|
211
|
+
stdout=subprocess.DEVNULL,
|
|
212
|
+
stderr=subprocess.DEVNULL,
|
|
213
|
+
text=True,
|
|
214
|
+
)
|
|
215
|
+
except (OSError, subprocess.SubprocessError):
|
|
216
|
+
return False
|
|
217
|
+
return proc.returncode == 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _check_branch_precondition(taskgroup) -> None:
|
|
221
|
+
"""Refuse a dispatch the repository cannot honour (card 88), before any slot.
|
|
222
|
+
|
|
223
|
+
A lane that declares ``branch = X`` is stating which checkout it may run on.
|
|
224
|
+
Nothing checked that before: the card landed on whatever HEAD the tree
|
|
225
|
+
happened to be on, and the only guard was a shell assertion hand-written at
|
|
226
|
+
the head of some lanes' `verify` command — which a `workspace = "worktree"`
|
|
227
|
+
lane cannot even use, because its long-lived checkout is created from the
|
|
228
|
+
main tree's HEAD and detached from any branch.
|
|
229
|
+
|
|
230
|
+
* ``workspace = "none"``: the tree must actually be ON that branch, so the
|
|
231
|
+
check is ``rev-parse --abbrev-ref HEAD`` == X (a detached checkout
|
|
232
|
+
reports ``HEAD``, and is refused).
|
|
233
|
+
* ``workspace = "worktree"``: the branch must exist, because that is the
|
|
234
|
+
commit the lane workspace is built from; the main tree may be anywhere.
|
|
235
|
+
|
|
236
|
+
A lane without the key is not gated at all — today's behaviour, unchanged.
|
|
237
|
+
Runs before the task row and before the concurrency claim, so a refusal
|
|
238
|
+
leaves the ledger empty and takes no cap slot (exit 78).
|
|
239
|
+
"""
|
|
240
|
+
branch = getattr(taskgroup, "branch", None)
|
|
241
|
+
if not branch:
|
|
242
|
+
return
|
|
243
|
+
path = taskgroup.path
|
|
244
|
+
if not os.path.isdir(path) or not _is_git_repo(path):
|
|
245
|
+
raise PreconditionError(
|
|
246
|
+
f"lane '{taskgroup.id}' declares branch '{branch}', but {path} "
|
|
247
|
+
"is not a git repository",
|
|
248
|
+
hint="check the lane's path, or drop the branch key",
|
|
249
|
+
)
|
|
250
|
+
if taskgroup.workspace == "worktree":
|
|
251
|
+
if not _has_branch_commit(path, branch):
|
|
252
|
+
raise PreconditionError(
|
|
253
|
+
f"lane '{taskgroup.id}' wants branch '{branch}', but {path} "
|
|
254
|
+
"has no such branch",
|
|
255
|
+
hint="fetch the branch, fix the name, or drop the branch key",
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
current = _head_branch(path)
|
|
259
|
+
if current != branch:
|
|
260
|
+
raise PreconditionError(
|
|
261
|
+
f"lane '{taskgroup.id}' wants branch '{branch}', the tree at "
|
|
262
|
+
f"{path} is on '{current}'",
|
|
263
|
+
hint="switch the tree to the declared branch, or drop the branch key",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
162
267
|
def dispatch(
|
|
163
268
|
workspace: str,
|
|
164
269
|
project_key: str,
|
|
@@ -183,7 +288,8 @@ def dispatch(
|
|
|
183
288
|
link survives even a failed rerun. ``taskproof rerun`` is the only caller.
|
|
184
289
|
|
|
185
290
|
Raises the typed errors in `errors.py` so the CLI can map them to exit codes:
|
|
186
|
-
ConcurrencyError -> 75, AdapterError -> 70, VerifyError -> 71,
|
|
291
|
+
ConcurrencyError -> 75, AdapterError -> 70, VerifyError -> 71,
|
|
292
|
+
RegistryError -> 2, PreconditionError -> 78
|
|
187
293
|
|
|
188
294
|
Rules that must survive any refactor:
|
|
189
295
|
* the acceptance command runs in the PROJECT workdir, never the workspace
|
|
@@ -199,6 +305,12 @@ def dispatch(
|
|
|
199
305
|
reg = registry.load(registry.workspace_registry_path(workspace))
|
|
200
306
|
taskgroup = reg.require(project_key)
|
|
201
307
|
|
|
308
|
+
# ①′ the branch gate (card 88): a lane that declares `branch = X` must find
|
|
309
|
+
# a repository that can honour it. Before the adapter is chosen, before a
|
|
310
|
+
# task id or a slot exists, so a refusal is a PreconditionError (exit 78)
|
|
311
|
+
# with no ledger trace and no cap slot taken.
|
|
312
|
+
_check_branch_precondition(taskgroup)
|
|
313
|
+
|
|
202
314
|
# The effective per-run timeout and where it came from (card 81): a
|
|
203
315
|
# `--timeout` wins over the lane's own `timeout`, which wins over
|
|
204
316
|
# `[defaults] timeout`, which wins over the built-in fallback.
|
|
@@ -808,6 +920,7 @@ def _execute(
|
|
|
808
920
|
"exit_code": verify_exit_field,
|
|
809
921
|
"note": note,
|
|
810
922
|
"output_tail": outcome.output_tail if outcome is not None else "",
|
|
923
|
+
"by": VERIFY_BY_RUN,
|
|
811
924
|
},
|
|
812
925
|
)
|
|
813
926
|
|
|
@@ -1589,9 +1702,22 @@ def _ensure_links(repo_root: str, workspace_root: str, links) -> list:
|
|
|
1589
1702
|
|
|
1590
1703
|
|
|
1591
1704
|
def _worktree_add(repo_root: str, path: str, lane) -> None:
|
|
1592
|
-
"""``git worktree add --detach`` a fresh, long-lived lane workspace.
|
|
1705
|
+
"""``git worktree add --detach`` a fresh, long-lived lane workspace.
|
|
1706
|
+
|
|
1707
|
+
A lane that declares `branch` gets its workspace built from that branch
|
|
1708
|
+
(card 88), so the checkout it runs in for the rest of its life is the one
|
|
1709
|
+
the registry names rather than whatever the main tree's HEAD happened to be
|
|
1710
|
+
on the day it was first used. `--detach` is not decoration: a branch can be
|
|
1711
|
+
checked out by only one worktree at a time and the main tree normally holds
|
|
1712
|
+
it, so a plain checkout of the same branch fails outright. A lane without
|
|
1713
|
+
the key is created exactly as before — detached at the main tree's HEAD —
|
|
1714
|
+
because the argument list is left untouched.
|
|
1715
|
+
"""
|
|
1716
|
+
argv = ["git", "worktree", "add", "--detach", path]
|
|
1717
|
+
if getattr(lane, "branch", None):
|
|
1718
|
+
argv.append(lane.branch)
|
|
1593
1719
|
proc = subprocess.run(
|
|
1594
|
-
|
|
1720
|
+
argv,
|
|
1595
1721
|
cwd=repo_root,
|
|
1596
1722
|
stdout=subprocess.PIPE,
|
|
1597
1723
|
stderr=subprocess.STDOUT,
|
|
@@ -1615,6 +1741,12 @@ def _ensure_lane_workspace(conn, task_id: str, lane, *, worktree_flag: bool = Fa
|
|
|
1615
1741
|
same repo is a hard error with the `workspace-rm` remedy. ``worktree_flag``
|
|
1616
1742
|
is the one-time ``--worktree`` argument: when set it is ignored, because the
|
|
1617
1743
|
lane workspace already is the long-lived checkout.
|
|
1744
|
+
|
|
1745
|
+
A lane that declares `branch` is created from that branch; an existing
|
|
1746
|
+
workspace is never re-created or moved onto a newly declared one, exactly
|
|
1747
|
+
like every other edit to a lane's configuration: what runs next changes,
|
|
1748
|
+
the checked-out tree on disk does not. Switch it with `workspace-rm` plus a
|
|
1749
|
+
new run if that is what you want.
|
|
1618
1750
|
"""
|
|
1619
1751
|
if not _is_git_repo(lane.path):
|
|
1620
1752
|
raise UsageError(
|
|
@@ -1901,6 +2033,7 @@ def verify_task(workspace: str, task_id: str) -> verify.VerifyOutcome:
|
|
|
1901
2033
|
"exit_code": outcome.exit_code,
|
|
1902
2034
|
"note": outcome.note,
|
|
1903
2035
|
"violations": outcome.violations,
|
|
2036
|
+
"by": VERIFY_BY_CLI,
|
|
1904
2037
|
},
|
|
1905
2038
|
)
|
|
1906
2039
|
return outcome
|
|
@@ -2072,25 +2205,33 @@ def _last_summary(conn, task_id: str) -> Optional[str]:
|
|
|
2072
2205
|
def accept_task(
|
|
2073
2206
|
workspace: str, task_id: str, *, by: str = "cli", note: Optional[str] = None
|
|
2074
2207
|
) -> dict:
|
|
2075
|
-
"""Clear a `blocked` or `
|
|
2208
|
+
"""Clear a `blocked`, `failed` or `timeout` card — the human disposition of
|
|
2209
|
+
one run.
|
|
2076
2210
|
|
|
2077
2211
|
`blocked` (a boundary breach) is accepted with or without a note, exactly as
|
|
2078
2212
|
it always has been: a green acceptance earns `done`, a red or skipped one
|
|
2079
2213
|
earns `failed`. `failed` (the adapter failed, or acceptance ran and went red)
|
|
2080
2214
|
may also be accepted, but ONLY with a non-empty ``note``: closing a failed
|
|
2081
|
-
card is a human sign-off
|
|
2215
|
+
card is a human sign-off that the recorded red was a false one, and the
|
|
2216
|
+
reason must survive in the ledger. `timeout` (the adapter hit the hard
|
|
2217
|
+
timeout) is accepted the same way — also ONLY with a non-empty ``note`` —
|
|
2218
|
+
because a timed-out run's artefacts are half-finished by definition and the
|
|
2219
|
+
sign-off means "a human has looked at this and kept the work", never "done".
|
|
2082
2220
|
|
|
2083
2221
|
`accept` is a human **registration**, not a verdict: it never re-runs
|
|
2084
2222
|
acceptance and never rewrites `verify_*`. Whatever the run recorded when it
|
|
2085
2223
|
stopped stays authoritative; a failed card is promoted to `done` only because
|
|
2086
|
-
a human judged the recorded failure a false red
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2224
|
+
a human judged the recorded failure a false red. A `timeout` card is not
|
|
2225
|
+
promoted at all: it stays `timeout` and the ledger simply gains one
|
|
2226
|
+
`accepted` event whose `from` and `to` are both `"timeout"` — from == to is
|
|
2227
|
+
how "registered, not re-judged" is written. Either judgement is kept in the
|
|
2228
|
+
`accepted` event alongside the evidence the human saw.
|
|
2229
|
+
|
|
2230
|
+
Every other status (running / verifying / done / cancelled) is refused with a
|
|
2231
|
+
readable `TaskStateError`, so `accept` can never silently re-close a card.
|
|
2232
|
+
The card's `verify_*` columns and `finished_at` are left untouched — the
|
|
2233
|
+
run's own facts do not change because a human later signed off. Returns the
|
|
2234
|
+
updated task row.
|
|
2094
2235
|
"""
|
|
2095
2236
|
note_text = note if isinstance(note, str) else None
|
|
2096
2237
|
note_present = bool(note_text and note_text.strip())
|
|
@@ -2101,20 +2242,23 @@ def accept_task(
|
|
|
2101
2242
|
if row is None:
|
|
2102
2243
|
raise TaskNotFoundError(f"no such task: {task_id}")
|
|
2103
2244
|
status = row["status"]
|
|
2104
|
-
if status not in (STATUS_BLOCKED, STATUS_FAILED):
|
|
2245
|
+
if status not in (STATUS_BLOCKED, STATUS_FAILED, STATUS_TIMEOUT):
|
|
2105
2246
|
raise TaskStateError(
|
|
2106
2247
|
f"task {task_id} is {status}; "
|
|
2107
|
-
"only a blocked or
|
|
2248
|
+
"only a blocked, failed or timed-out task can be accepted"
|
|
2108
2249
|
)
|
|
2109
|
-
if status
|
|
2110
|
-
# A failed card is closed by human judgement, not by a
|
|
2111
|
-
# so the reason MUST be on the record
|
|
2250
|
+
if status in (STATUS_FAILED, STATUS_TIMEOUT) and not note_present:
|
|
2251
|
+
# A failed or timed-out card is closed by human judgement, not by a
|
|
2252
|
+
# fresh verdict, so the reason MUST be on the record: a failed card's
|
|
2253
|
+
# sign-off says the red was false, a timed-out card's says the
|
|
2254
|
+
# half-finished work is being kept. Refuse before touching the row.
|
|
2255
|
+
what = "failed" if status == STATUS_FAILED else "timed-out"
|
|
2112
2256
|
raise UsageError(
|
|
2113
|
-
"accepting a
|
|
2257
|
+
f"accepting a {what} task requires a note: "
|
|
2114
2258
|
"人工收尾必须留说明",
|
|
2115
2259
|
hint=(
|
|
2116
2260
|
"accept is a human registration, not a verdict; pass "
|
|
2117
|
-
'--note "why this
|
|
2261
|
+
'--note "why this false red / timed-out result is kept"'
|
|
2118
2262
|
),
|
|
2119
2263
|
)
|
|
2120
2264
|
|
|
@@ -2129,6 +2273,11 @@ def accept_task(
|
|
|
2129
2273
|
# A human signed the red off as a false negative: the recorded facts
|
|
2130
2274
|
# stay, the terminal state becomes `done`.
|
|
2131
2275
|
new_status = STATUS_DONE
|
|
2276
|
+
elif status == STATUS_TIMEOUT:
|
|
2277
|
+
# Registration only: a timed-out card is not re-judged. Its terminal
|
|
2278
|
+
# state stays `timeout` and `finished_at` is left alone — the run's
|
|
2279
|
+
# own facts stand; the `accepted` event is the only thing added.
|
|
2280
|
+
new_status = status
|
|
2132
2281
|
else:
|
|
2133
2282
|
# `blocked` keeps following its acceptance result, exactly as before.
|
|
2134
2283
|
new_status = STATUS_DONE if verify_exit == 0 else STATUS_FAILED
|
|
@@ -2154,7 +2303,10 @@ def accept_task(
|
|
|
2154
2303
|
if agent_summary:
|
|
2155
2304
|
payload["summary"] = agent_summary[:_SUMMARY_TAIL_CHARS]
|
|
2156
2305
|
|
|
2157
|
-
|
|
2306
|
+
# A timeout is never promoted, so its row is not written at all; every
|
|
2307
|
+
# other path moves `status` (and only `status`).
|
|
2308
|
+
if new_status != status:
|
|
2309
|
+
storage.update_task(conn, task_id, status=new_status)
|
|
2158
2310
|
storage.append_event(conn, task_id, "accepted", payload)
|
|
2159
2311
|
return dict(storage.get_task(conn, task_id))
|
|
2160
2312
|
finally:
|
|
@@ -13,6 +13,9 @@ EXIT_USAGE = 64
|
|
|
13
13
|
EXIT_ADAPTER = 70
|
|
14
14
|
EXIT_VERIFY = 71
|
|
15
15
|
EXIT_CONCURRENCY = 75
|
|
16
|
+
#: sysexits' EX_CONFIG: the declaration is understood, the environment cannot
|
|
17
|
+
#: satisfy it. Here: the registry and the repository disagree about reality.
|
|
18
|
+
EXIT_PRECONDITION = 78
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
class TaskproofError(Exception):
|
|
@@ -53,3 +56,17 @@ class ConcurrencyError(TaskproofError):
|
|
|
53
56
|
"""Same-group serialisation or the global cap blocked this dispatch. -> 75"""
|
|
54
57
|
|
|
55
58
|
exit_code = EXIT_CONCURRENCY
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PreconditionError(TaskproofError):
|
|
62
|
+
"""What the registry declares and what the repository really is disagree.
|
|
63
|
+
|
|
64
|
+
Raised before the card exists — before a task row, before a concurrency
|
|
65
|
+
slot — so a refusal leaves no ledger trace at all. The same idea as
|
|
66
|
+
sysexits' ``EX_CONFIG`` (78): the configuration is readable and well formed,
|
|
67
|
+
but it cannot be honoured right now. Concretely, a lane that declares
|
|
68
|
+
``branch = X`` and finds a different branch checked out, a missing branch,
|
|
69
|
+
or no repository at all. -> 78
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
exit_code = EXIT_PRECONDITION
|
|
@@ -68,6 +68,13 @@ class Taskgroup:
|
|
|
68
68
|
#: (for example ``desktop/node_modules``). Empty means the dispatcher may
|
|
69
69
|
#: probe for package-manager dependency directories.
|
|
70
70
|
link: List[str] = field(default_factory=list)
|
|
71
|
+
#: Branch this lane declares as its home (card 88). ``None`` — the default —
|
|
72
|
+
#: means the lane says nothing, so no gate runs and a ``workspace =
|
|
73
|
+
#: "worktree"`` lane is still built from the main tree's current HEAD, byte
|
|
74
|
+
#: for byte as before. A declared name is checked against the repository at
|
|
75
|
+
#: dispatch time (a mismatch raises a precondition error, exit 78) and is
|
|
76
|
+
#: the commit a lane workspace is created from. See `dispatch`.
|
|
77
|
+
branch: Optional[str] = None
|
|
71
78
|
#: Concurrency lock name. Empty means this lane's own id; loading fills the
|
|
72
79
|
#: default in so callers never need to special-case an unset value.
|
|
73
80
|
group: str = ""
|
|
@@ -125,6 +125,41 @@ _LANE_FIELDS = (
|
|
|
125
125
|
"link",
|
|
126
126
|
)
|
|
127
127
|
|
|
128
|
+
#: Every key `_load_lane_fields` reads, i.e. every key that is a lane *setting*.
|
|
129
|
+
#: Deliberately NOT the same tuple as `_LANE_FIELDS` above: that one decides
|
|
130
|
+
#: whether a `[[project]]` block *is* a lane, so a setting that does not settle
|
|
131
|
+
#: that question (`timeout`, `branch`, `group`, ...) must stay out of it or a
|
|
132
|
+
#: bare project block would silently become a lane. This tuple is the other
|
|
133
|
+
#: direction — it is what the unknown-key warning uses, so adding a key to the
|
|
134
|
+
#: loader means adding it here too.
|
|
135
|
+
_LANE_SETTING_KEYS = (
|
|
136
|
+
"group",
|
|
137
|
+
"verify",
|
|
138
|
+
"verify_kind",
|
|
139
|
+
"forbidden_paths",
|
|
140
|
+
"auto_registered",
|
|
141
|
+
"result_schema",
|
|
142
|
+
"workspace",
|
|
143
|
+
"link",
|
|
144
|
+
"timeout",
|
|
145
|
+
"idle_timeout",
|
|
146
|
+
"branch",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
#: Keys a `[[project]]` block may carry without a warning: its own identity
|
|
150
|
+
#: plus every lane setting (the compat path reads a lane off exactly those).
|
|
151
|
+
_PROJECT_KEYS = frozenset(("id", "path", "aliases") + _LANE_SETTING_KEYS)
|
|
152
|
+
|
|
153
|
+
#: A `[[taskgroup]]` block is the same shape plus its owning project.
|
|
154
|
+
_TASKGROUP_KEYS = _PROJECT_KEYS | {"project"}
|
|
155
|
+
|
|
156
|
+
#: Keys `[defaults]` is actually read for — `Registry.concurrency`,
|
|
157
|
+
#: `Registry.timeout` / `registry.resolve_timeout`. `idle_timeout` is
|
|
158
|
+
#: deliberately absent: card 83 gave the idle watchdog no `[defaults]` rung
|
|
159
|
+
#: (`registry.resolve_idle_timeout`), so writing one there is ignored like any
|
|
160
|
+
#: typo and has to be warned about, not blessed.
|
|
161
|
+
_DEFAULTS_KEYS = frozenset(("concurrency", "timeout"))
|
|
162
|
+
|
|
128
163
|
|
|
129
164
|
class RegistryConflictError(RegistryError):
|
|
130
165
|
"""The caller's expected hash no longer matches the registry on disk."""
|
|
@@ -266,11 +301,19 @@ class Registry:
|
|
|
266
301
|
taskgroups: List[Taskgroup],
|
|
267
302
|
defaults: dict,
|
|
268
303
|
source: Optional[str] = None,
|
|
304
|
+
unknown_keys=None,
|
|
269
305
|
):
|
|
270
306
|
self.projects = projects
|
|
271
307
|
self.taskgroups = taskgroups
|
|
272
308
|
self.defaults = defaults or {}
|
|
273
309
|
self.source = source
|
|
310
|
+
#: `(block id, key)` for every key a block carried that its kind cannot
|
|
311
|
+
#: read, in file order and deduplicated. `[defaults]` names itself as
|
|
312
|
+
#: `"[defaults]"`. Empty is the normal case. Loading never fails on
|
|
313
|
+
#: these — `taskproof projects` / `taskgroups` warn on stderr instead,
|
|
314
|
+
#: which is how a silently dropped `timeout` (card 81's bug) would have
|
|
315
|
+
#: announced itself.
|
|
316
|
+
self.unknown_keys = list(unknown_keys or [])
|
|
274
317
|
|
|
275
318
|
# -- lookup -----------------------------------------------------------
|
|
276
319
|
|
|
@@ -674,6 +717,31 @@ def _load_lane_fields(
|
|
|
674
717
|
"workspace = 'worktree'"
|
|
675
718
|
)
|
|
676
719
|
|
|
720
|
+
# A lane's `branch` (card 88) is only a NAME here. Whether the repository
|
|
721
|
+
# actually has it is a question for dispatch time (the repo may not be on
|
|
722
|
+
# the machine yet, and a check at load time would make `projects` fail for a
|
|
723
|
+
# lane nobody is running). The shape, though, is settled now: a string that
|
|
724
|
+
# still has something in it once trimmed, and no whitespace inside — a
|
|
725
|
+
# `branch = "george main"` would otherwise be handed to `git`, which reads
|
|
726
|
+
# it as a revision range, not a branch.
|
|
727
|
+
branch = entry.get("branch")
|
|
728
|
+
if branch is not None:
|
|
729
|
+
if not isinstance(branch, str):
|
|
730
|
+
raise RegistryError(
|
|
731
|
+
f"{source}: {label} field 'branch' must be a string "
|
|
732
|
+
f"(got {type(branch).__name__})"
|
|
733
|
+
)
|
|
734
|
+
branch = branch.strip()
|
|
735
|
+
if not branch:
|
|
736
|
+
raise RegistryError(
|
|
737
|
+
f"{source}: {label} field 'branch' must not be empty"
|
|
738
|
+
)
|
|
739
|
+
if any(character.isspace() for character in branch):
|
|
740
|
+
raise RegistryError(
|
|
741
|
+
f"{source}: {label} field 'branch' must not contain "
|
|
742
|
+
f"whitespace: {branch!r}"
|
|
743
|
+
)
|
|
744
|
+
|
|
677
745
|
# A lane timeout, when written, must be a positive integer. TOML gives us
|
|
678
746
|
# bool/int/float here, so reject every wrong type up front rather than
|
|
679
747
|
# letting a truthy `true` masquerade as 1 second. Missing stays `None`:
|
|
@@ -704,6 +772,7 @@ def _load_lane_fields(
|
|
|
704
772
|
"result_schema": result_schema,
|
|
705
773
|
"workspace": workspace,
|
|
706
774
|
"link": link,
|
|
775
|
+
"branch": branch,
|
|
707
776
|
"timeout": timeout,
|
|
708
777
|
"idle_timeout": idle_timeout,
|
|
709
778
|
}
|
|
@@ -851,7 +920,35 @@ def load(path: str) -> Registry:
|
|
|
851
920
|
defaults = data.get("defaults", {})
|
|
852
921
|
if not isinstance(defaults, dict):
|
|
853
922
|
raise RegistryError(f"{path}: 'defaults' must be a table")
|
|
854
|
-
|
|
923
|
+
|
|
924
|
+
# Card 88: every key a block carries that its kind has no reader for. The
|
|
925
|
+
# ids are guaranteed non-empty strings by here (both loops above refuse a
|
|
926
|
+
# block without one first), so this pass only has to walk the blocks.
|
|
927
|
+
# `data` keeps keys in the order they first appear in the document, which is
|
|
928
|
+
# what "in order of appearance" means for a reader looking at the file.
|
|
929
|
+
unknown_keys: List[tuple] = []
|
|
930
|
+
recorded = set()
|
|
931
|
+
|
|
932
|
+
def note(block: str, entry: dict, known) -> None:
|
|
933
|
+
for key in entry:
|
|
934
|
+
if key in known or (block, key) in recorded:
|
|
935
|
+
continue
|
|
936
|
+
recorded.add((block, key))
|
|
937
|
+
unknown_keys.append((block, key))
|
|
938
|
+
|
|
939
|
+
for name, value in data.items():
|
|
940
|
+
if name == "defaults" and isinstance(value, dict):
|
|
941
|
+
note("[defaults]", value, _DEFAULTS_KEYS)
|
|
942
|
+
elif name == "project" and isinstance(value, list):
|
|
943
|
+
for entry in value:
|
|
944
|
+
note(entry["id"], entry, _PROJECT_KEYS)
|
|
945
|
+
elif name == "taskgroup" and isinstance(value, list):
|
|
946
|
+
for entry in value:
|
|
947
|
+
note(entry["id"], entry, _TASKGROUP_KEYS)
|
|
948
|
+
|
|
949
|
+
return Registry(
|
|
950
|
+
projects, taskgroups, defaults, source=path, unknown_keys=unknown_keys
|
|
951
|
+
)
|
|
855
952
|
|
|
856
953
|
|
|
857
954
|
def workspace_registry_path(workspace: str) -> str:
|
|
@@ -917,6 +1014,9 @@ def taskgroup_record(taskgroup: Taskgroup, probe_flags: Optional[dict] = None) -
|
|
|
917
1014
|
"result_schema": taskgroup.result_schema,
|
|
918
1015
|
"timeout": taskgroup.timeout,
|
|
919
1016
|
"idle_timeout": taskgroup.idle_timeout,
|
|
1017
|
+
# Card 88: the declared branch, or null when the lane declares none
|
|
1018
|
+
# (the shape `timeout` beside it uses for "not declared").
|
|
1019
|
+
"branch": taskgroup.branch,
|
|
920
1020
|
"auto_registered": bool(taskgroup.auto_registered),
|
|
921
1021
|
"probe": probe,
|
|
922
1022
|
"probe_exit": probe_exit,
|