taskproof 0.3.0__tar.gz → 0.4.0__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.
Files changed (82) hide show
  1. {taskproof-0.3.0 → taskproof-0.4.0}/PKG-INFO +15 -8
  2. {taskproof-0.3.0 → taskproof-0.4.0}/README.md +13 -7
  3. {taskproof-0.3.0 → taskproof-0.4.0}/pyproject.toml +1 -0
  4. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/__init__.py +1 -1
  5. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/cli.py +22 -3
  6. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/dispatch.py +174 -22
  7. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/errors.py +17 -0
  8. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/models.py +7 -0
  9. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/registry.py +101 -1
  10. taskproof-0.3.0/src/taskproof/web/assets/MatrixView-BMsawnxh.js → taskproof-0.4.0/src/taskproof/web/assets/MatrixView-ClSEIjZm.js +2 -2
  11. taskproof-0.4.0/src/taskproof/web/assets/MatrixView-ClSEIjZm.js.gz +0 -0
  12. taskproof-0.3.0/src/taskproof/web/assets/ProjectsView-BcXcx5Gw.js → taskproof-0.4.0/src/taskproof/web/assets/ProjectsView-BXWsK6eF.js +2 -2
  13. taskproof-0.4.0/src/taskproof/web/assets/ProjectsView-BXWsK6eF.js.gz +0 -0
  14. taskproof-0.3.0/src/taskproof/web/assets/SettingsView-D_-waf0s.js → taskproof-0.4.0/src/taskproof/web/assets/SettingsView-Co6AsKsi.js +1 -1
  15. taskproof-0.4.0/src/taskproof/web/assets/SettingsView-Co6AsKsi.js.gz +0 -0
  16. taskproof-0.3.0/src/taskproof/web/assets/TaskDrawer-6JoGTH0s.js → taskproof-0.4.0/src/taskproof/web/assets/TaskDrawer-COqi93G5.js +1 -1
  17. taskproof-0.4.0/src/taskproof/web/assets/TaskDrawer-COqi93G5.js.gz +0 -0
  18. taskproof-0.3.0/src/taskproof/web/assets/TasksView-BWhSHCHi.js → taskproof-0.4.0/src/taskproof/web/assets/TasksView-CgOmskO0.js +2 -2
  19. taskproof-0.4.0/src/taskproof/web/assets/TasksView-CgOmskO0.js.gz +0 -0
  20. taskproof-0.3.0/src/taskproof/web/assets/index-B-PLdDbR.js → taskproof-0.4.0/src/taskproof/web/assets/index-Cd1EoUu-.js +6 -6
  21. taskproof-0.3.0/src/taskproof/web/assets/index-B-PLdDbR.js.gz → taskproof-0.4.0/src/taskproof/web/assets/index-Cd1EoUu-.js.gz +0 -0
  22. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/index.html +1 -1
  23. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof.egg-info/PKG-INFO +15 -8
  24. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof.egg-info/SOURCES.txt +12 -12
  25. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_accept_rerun.py +111 -7
  26. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_api.py +5 -4
  27. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_cli.py +142 -1
  28. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_dispatch.py +260 -1
  29. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_registry.py +236 -0
  30. taskproof-0.3.0/src/taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  31. taskproof-0.3.0/src/taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  32. taskproof-0.3.0/src/taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  33. taskproof-0.3.0/src/taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  34. taskproof-0.3.0/src/taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  35. {taskproof-0.3.0 → taskproof-0.4.0}/LICENSE +0 -0
  36. {taskproof-0.3.0 → taskproof-0.4.0}/setup.cfg +0 -0
  37. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/__main__.py +0 -0
  38. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/adapters/__init__.py +0 -0
  39. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/adapters/base.py +0 -0
  40. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/adapters/claude.py +0 -0
  41. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/adapters/codex.py +0 -0
  42. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/adapters/custom.py +0 -0
  43. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/adapters/gemini.py +0 -0
  44. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/adapters/opencode.py +0 -0
  45. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/api/__init__.py +0 -0
  46. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/api/server.py +0 -0
  47. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/concurrency.py +0 -0
  48. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/ledger.py +0 -0
  49. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/schemas/result.json +0 -0
  50. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/sql/schema.sql +0 -0
  51. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/storage.py +0 -0
  52. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/verify.py +0 -0
  53. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/MatrixView-BM8nu9s2.css +0 -0
  54. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  55. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/ProjectsView-CqNFL-fi.css +0 -0
  56. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/SettingsView-aqTS3ubB.css +0 -0
  57. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/TaskDrawer-N299VKzd.css +0 -0
  58. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  59. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/TasksView-Bn08731V.css +0 -0
  60. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  61. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/index-NKj7kbYl.css +0 -0
  62. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  63. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof.egg-info/dependency_links.txt +0 -0
  64. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof.egg-info/entry_points.txt +0 -0
  65. {taskproof-0.3.0 → taskproof-0.4.0}/src/taskproof.egg-info/top_level.txt +0 -0
  66. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_adapters.py +0 -0
  67. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_adapters_parse.py +0 -0
  68. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_api_cors.py +0 -0
  69. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_api_log.py +0 -0
  70. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_board_cli.py +0 -0
  71. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_board_serve.py +0 -0
  72. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_concurrency.py +0 -0
  73. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_concurrency_race.py +0 -0
  74. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_control_surface.py +0 -0
  75. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_ledger.py +0 -0
  76. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_project_view.py +0 -0
  77. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_registry_write.py +0 -0
  78. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_result_schema.py +0 -0
  79. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_scan_assets.py +0 -0
  80. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_scope.py +0 -0
  81. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_storage.py +0 -0
  82. {taskproof-0.3.0 → taskproof-0.4.0}/tests/test_verify.py +0 -0
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskproof
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
- Or download the wheel from the same Release and install the local file:
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
- Or download the wheel from the same Release and install the local file:
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
@@ -26,6 +26,7 @@ dependencies = []
26
26
 
27
27
  [project.urls]
28
28
  Homepage = "https://github.com/HaoHaoP/taskproof"
29
+ Documentation = "https://taskproof.haohaop.com/"
29
30
  Issues = "https://github.com/HaoHaoP/taskproof/issues"
30
31
 
31
32
  [project.scripts]
@@ -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.3.0"
11
+ __version__ = "0.4.0"
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 failed task: done if acceptance passed (a failed "
163
- "card needs --note), else failed"
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 EXIT_VERIFY, AdapterError, ConcurrencyError, UsageError, VerifyError
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, RegistryError -> 2
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
- ["git", "worktree", "add", "--detach", path],
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 `failed` card — the human disposition of one run.
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, and the reason must survive in the ledger.
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, and that judgement is kept
2087
- in the `accepted` event alongside the evidence the human saw.
2088
-
2089
- Every other status (running / verifying / done / timeout /
2090
- cancelled) is refused with a readable `TaskStateError`, so `accept` can never
2091
- silently re-close a card. The card's `verify_*` columns and `finished_at` are
2092
- left untouched — the run's own facts do not change because a human later
2093
- signed off. Returns the updated task row.
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 failed task can be accepted"
2248
+ "only a blocked, failed or timed-out task can be accepted"
2108
2249
  )
2109
- if status == STATUS_FAILED and not note_present:
2110
- # A failed card is closed by human judgement, not by a fresh verdict,
2111
- # so the reason MUST be on the record. Refuse before touching the row.
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 failed task requires a note: "
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 failure is being cleared"'
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
- storage.update_task(conn, task_id, status=new_status)
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
- return Registry(projects, taskgroups, defaults, source=path)
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,