chipgraph 0.1.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.
Files changed (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2285 @@
1
+ """What the MCP tools of runtime `claude-code` do: `next_task`, `get_context`, `submit`.
2
+
3
+ The loop (DESIGN.md 5.5, spike S7), driven by the plugin command in the user's Claude
4
+ Code session::
5
+
6
+ next_task(target) run the build; every agent task it reaches is queued; hand out
7
+ the ready ones (several at once: they run in parallel)
8
+ get_context(id) a role subagent asks for its task: inputs as text, outputs,
9
+ role, skills and their text (refused when an input is labelled
10
+ `nda`, or is of a kind the task's role must not see)
11
+ submit(id, result) the engine checks the work at once: every file changed since
12
+ dispatch is one of the task's outputs, the outputs exist and are
13
+ not empty, the rule's checks pass; accept, or reject with the
14
+ failure's label, the failed checks and the redo instruction
15
+
16
+ `submit` runs the M2-02a agent rule loop one attempt at a time (`loop`): a failure is
17
+ labelled, a try is counted (an infra failure is not, within `max_infra_retries`), the
18
+ model tier escalates after the first failed try, and two tries in a row with the same
19
+ outputs and failures stop early. When the budget is gone the task turns
20
+ `budget_exhausted`, its `rule_fail` (with the label and the loop's `agent` summary) goes
21
+ into the journal of the run that dispatched it, and that run's HANDOFF.md is written at
22
+ once. `next_task` never hands a task out more than `tries + max_infra_retries` times,
23
+ never hands out a stopped task, and reports it (and every rule that depends on it)
24
+ under `blocked`; with nothing left to run it answers `stopped: true` and the HANDOFF
25
+ path.
26
+
27
+ A role that must not see some artifact kinds gets a guarded context (`guarded`). The
28
+ testbench Author (no `rtl`): its model inputs are the block's spec side and the module's
29
+ interface (spec ports, else the model's RTL ports, else the RTL declaration only), never
30
+ a general model slice. The RTL Author (no `tb`, M2-04): an `rtl` section with the
31
+ module's approved plan entry, requirements, interface, dependencies, naming rules and
32
+ style exemplars, and nothing from a testbench. For both, what `submit` tells it of
33
+ failed checks goes through `FeedbackFilter` (no issue in a denied file, no denied path,
34
+ no source excerpt; the redo text says when something was withheld, in words for the kind
35
+ withheld). The journal keeps the full results.
36
+
37
+ A role with no file-reading tool (`tb-author`, `rtl-author`) cannot overwrite a file
38
+ in Claude Code, which refuses a `Write` over a file the agent has not read. So at each
39
+ dispatch of such a role `next_task` moves every output that exists into the state
40
+ backend (`previous`): the agent's earlier attempt, or a file that was there before the
41
+ task. `get_context` shows them (`previous_outputs`: the last attempt's text, and a
42
+ pre-existing file's under `existing_output`, capped), the agent writes each file anew,
43
+ and a pre-existing file is put back, byte for byte with its mode, when the task ends
44
+ without being accepted (`budget_exhausted`, `needs_human`, a rewind) or the dispatch
45
+ fails in the engine. Roles with a read tool edit in place, as before.
46
+
47
+ For a rule whose first check is `format`, that check (the engine, not the agent) formats
48
+ the outputs in place before the other checks; the hashes `submit` records are taken
49
+ after the checks, so they are the formatted files'.
50
+
51
+ A role that writes no files but has `engine_writes` (the Critic) reviews a change
52
+ instead (`review`): its context holds the diff, the spec slice and the model slice; its
53
+ reply is a JSON review the main session passes as `result.review`; `submit` validates it
54
+ and the engine itself writes it as the task's one output.
55
+
56
+ A person who rejects a gate with a note (`approve(reject=true, note=...)`, or
57
+ `chipgraph approve <gate> --reject --note ...`) asks for a revision (`_revise_rejected`):
58
+ when every file the gate covers is an accepted agent task's output, the next `next_task`
59
+ reopens that task (a new budget cycle), rewinds it in the build and hands it out again;
60
+ its context starts `previous_rejection` with the note until it is accepted again. The
61
+ answer lists it under `revised`. The gate then waits for the new output.
62
+
63
+ The engine decides everything (P3): which task, which role, which files, which checks.
64
+ Claude Code only runs the model. Each role's tool table (`chipgraph.core.runtime.roles`)
65
+ picks the subagent (`chipgraph:<role>`), its model per attempt (the role's tier, then its
66
+ escalation tier after a rejection, unless the rule sets its own), and what it must not
67
+ read (`denied_reads`, set on dispatch). The guard hook (`plugin/hooks/guard.py`) blocks
68
+ writes outside a task's outputs and reads of its denied paths while the agent works;
69
+ `submit` checks the diff again.
70
+
71
+ Every function takes a freshly loaded `AppContext` and raises `AppError` for anything
72
+ the caller should see as a tool error.
73
+ """
74
+
75
+ from __future__ import annotations
76
+
77
+ import contextlib
78
+ import json
79
+ import os
80
+ from collections.abc import Callable, Iterable, Mapping
81
+ from datetime import UTC, datetime
82
+ from pathlib import Path
83
+ from typing import Any, Literal
84
+
85
+ from pydantic import BaseModel, ConfigDict, Field
86
+
87
+ from chipgraph.adapters.runtime.claude_code import loop
88
+ from chipgraph.adapters.runtime.claude_code import review as review_mod
89
+ from chipgraph.adapters.runtime.claude_code.agents import DEFAULT_TIER_MODELS, agent_type
90
+ from chipgraph.adapters.runtime.claude_code.guarded import (
91
+ GuardedContext,
92
+ RtlAuthorContext,
93
+ guarded_context,
94
+ )
95
+ from chipgraph.adapters.runtime.claude_code.isolation import (
96
+ CopyBackResult,
97
+ best_of_n,
98
+ candidate_id,
99
+ check_overlapping_writes,
100
+ copy_outputs_back,
101
+ create_workspace,
102
+ diff_workspace,
103
+ input_paths_for,
104
+ is_candidate_id,
105
+ max_parallel_limit,
106
+ parse_candidate_id,
107
+ remove_workspace,
108
+ should_isolate,
109
+ sweep_workspaces,
110
+ )
111
+ from chipgraph.adapters.runtime.claude_code.previous import (
112
+ MoveToken,
113
+ PreviousOutputs,
114
+ moves_outputs,
115
+ )
116
+ from chipgraph.adapters.runtime.claude_code.runtime import output_hashes
117
+ from chipgraph.app.build import graph_artifacts, make_scheduler, pack_search_paths
118
+ from chipgraph.app.checks import ProfileCheckRunner
119
+ from chipgraph.app.context import AppContext
120
+ from chipgraph.app.errors import AppError
121
+ from chipgraph.core.contracts import (
122
+ AgentResult,
123
+ Approval,
124
+ CheckResult,
125
+ Event,
126
+ RuleInstance,
127
+ RuleSpec,
128
+ )
129
+ from chipgraph.core.contracts.event import EventType
130
+ from chipgraph.core.contracts.types import FailureLabel, ModelTier
131
+ from chipgraph.core.engine.agent_rule import OUTPUTS_CHECK, FeedbackFilter, attempt_checks
132
+ from chipgraph.core.engine.gate import gate_id_for
133
+ from chipgraph.core.engine.graph import BuildGraph, kind_for
134
+ from chipgraph.core.engine.scheduler import RunSummary, Scheduler
135
+ from chipgraph.core.engine.select import (
136
+ VARIANTS,
137
+ Candidate,
138
+ Selection,
139
+ Spend,
140
+ error_count,
141
+ plan_budget,
142
+ select_best,
143
+ warning_count,
144
+ )
145
+ from chipgraph.core.model import ModelQuery, ModelStore, QueryError, default_model_db_path
146
+ from chipgraph.core.plugin_api.pack import discover_packs
147
+ from chipgraph.core.plugin_api.registry import PluginError
148
+ from chipgraph.core.runtime import (
149
+ ACTIVE_STATUSES,
150
+ OPEN_STATUSES,
151
+ AgentTaskRecord,
152
+ QueueError,
153
+ TaskQueue,
154
+ instance_inputs_hash,
155
+ state_key,
156
+ task_for,
157
+ )
158
+ from chipgraph.core.runtime.queue import TaskBudgetState
159
+ from chipgraph.core.runtime.roles import (
160
+ ReviewReport,
161
+ RoleSpec,
162
+ SkillError,
163
+ SkillSet,
164
+ SkillSpec,
165
+ denied_kind,
166
+ find_role,
167
+ load_skills,
168
+ parse_review,
169
+ path_denied,
170
+ review_problems,
171
+ task_denied_reads,
172
+ )
173
+ from chipgraph.core.state import journal as journal_mod
174
+ from chipgraph.core.state.artifacts import ArtifactStore, hash_file
175
+ from chipgraph.core.state.handoff import build_handoff, write_handoff
176
+
177
+ _MAX_INPUT_CHARS = 200_000
178
+ """Longest input text `get_context` returns per file; longer files are cut."""
179
+
180
+ _NOT_AGENT_WORK = (".git/", ".chipgraph/state/", ".chipgraph/decisions/", ".claude/")
181
+ """Paths never counted as an agent's changes: VCS and chipgraph state, gate decisions a
182
+ person records during the run, and Claude Code's own project settings. The write guard
183
+ still refuses agent writes to all of them."""
184
+
185
+
186
+ class SubmitReport(BaseModel):
187
+ """What the role subagent reported, passed along by the main session on `submit`."""
188
+
189
+ model_config = ConfigDict(extra="forbid", frozen=True)
190
+
191
+ status: Literal["done", "needs_human"] = Field(
192
+ default="done",
193
+ description="'needs_human' when the task cannot be done without a person's answer.",
194
+ )
195
+ files_written: tuple[str, ...] = Field(
196
+ default=(), description="Files the agent says it wrote (the engine checks the diff)."
197
+ )
198
+ assumptions: tuple[str, ...] = Field(default=(), description="Assumptions the agent made.")
199
+ open_questions: tuple[str, ...] = Field(
200
+ default=(), description="Questions for a person; required when status is needs_human."
201
+ )
202
+ review: dict[str, Any] | str | None = Field(
203
+ default=None,
204
+ description=(
205
+ "Review tasks only (agent chipgraph:critic): the subagent's JSON review, as an "
206
+ "object or its text. The engine validates it and writes it as the task's output."
207
+ ),
208
+ )
209
+
210
+
211
+ # --- Best-of-N round tracking -----------------------------------------------------------
212
+
213
+
214
+ class CandidateState(BaseModel):
215
+ """State of one candidate in a best-of-N round."""
216
+
217
+ model_config = ConfigDict(extra="forbid", frozen=True)
218
+
219
+ candidate: int = Field(ge=1, description="Candidate number (1..N).")
220
+ variant: str = Field(description="The variant hint for this candidate.")
221
+ task_id: str = Field(description="The candidate's task id (base_task#candidate).")
222
+ workspace: str = Field(description="Absolute path to the candidate's workspace.")
223
+ status: Literal["dispatched", "submitted", "done", "failed", "cancelled"] = Field(
224
+ default="dispatched"
225
+ )
226
+ checks: tuple[CheckResult, ...] = Field(default=(), description="Check results.")
227
+ passed: bool = Field(default=False, description="Whether the candidate passed.")
228
+ errors: int = Field(default=0, ge=0)
229
+ warnings: int = Field(default=0, ge=0)
230
+ spend: Spend = Field(default_factory=Spend)
231
+
232
+
233
+ class BestOfNRound(BaseModel):
234
+ """State of one best-of-N round for a rule instance.
235
+
236
+ Stored under <state>/runtime/best-of/<sha256(task_id)>.json while in progress.
237
+ """
238
+
239
+ model_config = ConfigDict(extra="forbid", frozen=True)
240
+
241
+ schema_version: Literal[1] = Field(default=1)
242
+ base_task_id: str = Field(description="The original task id (without candidate suffix).")
243
+ run_id: str = Field(description="The run that started this round.")
244
+ round_num: int = Field(ge=1, description="Round number (1-based).")
245
+ n: int = Field(ge=2, description="Number of candidates in this round.")
246
+ candidates: tuple[CandidateState, ...] = Field(description="State of each candidate.")
247
+ ledger_tries: int = Field(description="Total tries in the shared ledger.")
248
+ ledger_used: int = Field(default=0, ge=0, description="Tries used so far.")
249
+ winner: int | None = Field(default=None, description="Winning candidate number, if decided.")
250
+ selection: Selection | None = Field(default=None, description="The selection result.")
251
+ status: Literal["running", "selecting", "done", "failed"] = Field(default="running")
252
+
253
+
254
+ def _best_of_path(ctx: AppContext, task_id: str) -> Path:
255
+ """Path to a best-of-N round state file."""
256
+ return ctx.layout.state_dir / "runtime" / "best-of" / f"{state_key(task_id)}.json"
257
+
258
+
259
+ def _load_best_of_round(ctx: AppContext, task_id: str) -> BestOfNRound | None:
260
+ """Load a best-of-N round state, or None if not in a round."""
261
+ path = _best_of_path(ctx, task_id)
262
+ if not path.is_file():
263
+ return None
264
+ try:
265
+ return BestOfNRound.model_validate_json(path.read_text(encoding="utf-8"))
266
+ except Exception:
267
+ return None
268
+
269
+
270
+ def _save_best_of_round(ctx: AppContext, round_state: BestOfNRound) -> None:
271
+ """Save a best-of-N round state."""
272
+ path = _best_of_path(ctx, round_state.base_task_id)
273
+ path.parent.mkdir(parents=True, exist_ok=True)
274
+ path.write_text(round_state.model_dump_json(indent=2), encoding="utf-8")
275
+
276
+
277
+ def _remove_best_of_round(ctx: AppContext, task_id: str) -> None:
278
+ """Remove a best-of-N round state file."""
279
+ path = _best_of_path(ctx, task_id)
280
+ path.unlink(missing_ok=True)
281
+
282
+
283
+ # --- shared helpers ---------------------------------------------------------------------
284
+
285
+
286
+ def _require_runtime(ctx: AppContext) -> None:
287
+ runtime = ctx.require_profile().profile.runtime
288
+ if runtime != "claude-code":
289
+ raise AppError(
290
+ f"this project's profile sets runtime {runtime!r}; next_task, get_context and "
291
+ "submit serve runtime 'claude-code' only"
292
+ )
293
+
294
+
295
+ def _queue(ctx: AppContext) -> TaskQueue:
296
+ return TaskQueue(ctx.layout)
297
+
298
+
299
+ def _record(queue: TaskQueue, task_id: str) -> AgentTaskRecord:
300
+ try:
301
+ return queue.require(task_id)
302
+ except QueueError as exc:
303
+ raise AppError(f"{exc}; call next_task for the tasks that exist") from exc
304
+
305
+
306
+ def _graph(ctx: AppContext) -> BuildGraph:
307
+ return make_scheduler(ctx, "*").graph
308
+
309
+
310
+ def _instance(graph: BuildGraph, task_id: str) -> tuple[RuleSpec, RuleInstance]:
311
+ instance = graph.instances.get(task_id)
312
+ if instance is None and is_candidate_id(task_id): # best-of-N: `<instance id>#<n>`
313
+ try:
314
+ instance = graph.instances.get(parse_candidate_id(task_id)[0])
315
+ except ValueError:
316
+ instance = None
317
+ if instance is None:
318
+ raise AppError(f"task {task_id!r} is no longer a rule instance of this project's graph")
319
+ return graph.rules[instance.rule_id], instance
320
+
321
+
322
+ def _agent_for(role: str) -> str:
323
+ """The plugin subagent for a role: `chipgraph:<role>` (namespace dropped)."""
324
+ return agent_type(role)
325
+
326
+
327
+ def _model_for(ctx: AppContext, record: AgentTaskRecord) -> str:
328
+ """The model of the task's next attempt: its current tier, mapped by the profile."""
329
+ return _tier_model(ctx, record.current_tier)
330
+
331
+
332
+ def _tier_model(ctx: AppContext, tier: ModelTier) -> str:
333
+ configured = ctx.require_profile().profile.models.tiers.get(tier)
334
+ return configured or DEFAULT_TIER_MODELS[tier]
335
+
336
+
337
+ def _skill_set(ctx: AppContext) -> SkillSet:
338
+ """Every skill the profile's packs provide."""
339
+ profile = ctx.require_profile().profile
340
+ try:
341
+ available = discover_packs(pack_search_paths(ctx))
342
+ return load_skills(available[name] for name in profile.packs if name in available)
343
+ except (PluginError, SkillError) as exc:
344
+ raise AppError(str(exc)) from exc
345
+
346
+
347
+ def _check_skills(ctx: AppContext, graph: BuildGraph) -> SkillSet:
348
+ """Resolve every agent rule's skills for its role; an unknown or misused one is an error."""
349
+ skills = _skill_set(ctx)
350
+ for rule in sorted(graph.rules.values(), key=lambda r: r.id):
351
+ if rule.kind == "agent" and rule.role is not None:
352
+ try:
353
+ skills.resolve(rule.skills, rule.role)
354
+ except SkillError as exc:
355
+ raise AppError(f"rule {rule.id!r}: {exc}") from exc
356
+ return skills
357
+
358
+
359
+ def _denied_reads(ctx: AppContext, graph: BuildGraph, record: AgentTaskRecord) -> tuple[str, ...]:
360
+ """What the task's agent must not read: its role's read policy over this project."""
361
+ return task_denied_reads(
362
+ record.role,
363
+ artifacts=graph_artifacts(graph),
364
+ profile=ctx.require_profile().profile,
365
+ keep=record.allowed_writes,
366
+ )
367
+
368
+
369
+ def _emit(
370
+ ctx: AppContext,
371
+ run_id: str | None,
372
+ task_id: str,
373
+ type_: EventType,
374
+ payload: Mapping[str, Any],
375
+ *,
376
+ failure_label: FailureLabel | None = None,
377
+ ) -> None:
378
+ """Append one event about `task_id` to the journal of the run that dispatched it."""
379
+ if run_id is None:
380
+ return
381
+ journal = journal_mod.Journal(ctx.layout.journal(run_id))
382
+ journal.append(
383
+ Event(
384
+ run_id=run_id,
385
+ seq=journal.next_seq(),
386
+ ts=datetime.now(UTC),
387
+ type=type_,
388
+ rule_instance=task_id,
389
+ payload=dict(payload),
390
+ failure_label=failure_label,
391
+ )
392
+ )
393
+
394
+
395
+ def _handoff_path(ctx: AppContext, run_id: str) -> Path:
396
+ """Where a run's HANDOFF.md is (`write_handoff` writes it there)."""
397
+ return ctx.layout.run_dir(run_id) / "HANDOFF.md"
398
+
399
+
400
+ def _write_run_handoff(ctx: AppContext, run_id: str) -> Path | None:
401
+ """Write run `run_id`'s HANDOFF.md again from its journal; `None` if that failed.
402
+
403
+ As in the scheduler, a HANDOFF.md that cannot be written (an `OSError`) never fails
404
+ the call itself.
405
+ """
406
+ try:
407
+ events = journal_mod.read(ctx.layout.journal(run_id)).events
408
+ target = journal_mod.read_manifest(ctx.layout, run_id).target
409
+ return write_handoff(ctx.layout, build_handoff(events, target=target))
410
+ except OSError:
411
+ return None
412
+
413
+
414
+ async def snapshot(ctx: AppContext, extra: Iterable[str] = ()) -> dict[str, str]:
415
+ """Content hash of every project file an agent could have changed, keyed by path.
416
+
417
+ Files come from `git ls-files --cached --others --exclude-standard` (tracked plus
418
+ untracked, without ignored ones such as build products); without git, from a walk
419
+ of the tree. `extra` paths (a task's outputs) are always included.
420
+ """
421
+ runner = ctx.registry.get("runner", "local")
422
+ result = await runner.run(
423
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd=ctx.root
424
+ )
425
+ if result.returncode == 0:
426
+ paths = {p for p in result.stdout.split("\0") if p}
427
+ else:
428
+ paths = set()
429
+ for dirpath, dirnames, filenames in os.walk(ctx.root):
430
+ rel_dir = Path(dirpath).relative_to(ctx.root).as_posix()
431
+ dirnames[:] = [
432
+ d
433
+ for d in dirnames
434
+ if not f"{'' if rel_dir == '.' else rel_dir + '/'}{d}/".startswith(_NOT_AGENT_WORK)
435
+ ]
436
+ for name in filenames:
437
+ paths.add(name if rel_dir == "." else f"{rel_dir}/{name}")
438
+ paths |= set(extra)
439
+ hashes: dict[str, str] = {}
440
+ for rel in sorted(paths):
441
+ if rel.startswith(_NOT_AGENT_WORK):
442
+ continue
443
+ full = ctx.root / rel
444
+ if full.is_file():
445
+ hashes[rel] = hash_file(full)
446
+ return hashes
447
+
448
+
449
+ def _changed(before: Mapping[str, str], after: Mapping[str, str]) -> list[str]:
450
+ return sorted(p for p in set(before) | set(after) if before.get(p) != after.get(p))
451
+
452
+
453
+ def _engine_outputs(graph: BuildGraph) -> set[str]:
454
+ """Outputs of the graph's non-agent rules: the engine itself writes those."""
455
+ return {
456
+ ref.path
457
+ for instance in graph.instances.values()
458
+ if graph.rules[instance.rule_id].kind != "agent"
459
+ for ref in instance.outputs
460
+ if ref.path is not None
461
+ }
462
+
463
+
464
+ def _task_entry(
465
+ ctx: AppContext,
466
+ queue: TaskQueue,
467
+ record: AgentTaskRecord,
468
+ *,
469
+ candidate: int | None = None,
470
+ variant: str | None = None,
471
+ ) -> dict[str, Any]:
472
+ """Build a task entry for the next_task response.
473
+
474
+ For best-of-N candidates, `candidate` is the candidate number (1..N) and `variant`
475
+ is the variant hint text.
476
+ """
477
+ state = record.state
478
+ if review_mod.is_review_role(find_role(record.role)):
479
+ prompt = (
480
+ f"You are doing chipgraph review task {record.task_id!r}. First call get_context "
481
+ f"with task_id={record.task_id!r}; write no file; finish with the review as one "
482
+ "JSON object."
483
+ )
484
+ extra: dict[str, Any] = {"reply": "review"}
485
+ else:
486
+ prompt = (
487
+ f"You are doing chipgraph task {record.task_id!r}. First call get_context with "
488
+ f"task_id={record.task_id!r}, then write only the files in its outputs."
489
+ )
490
+ extra = {}
491
+ entry = {
492
+ "task_id": record.task_id,
493
+ "rule": record.rule_id,
494
+ "role": record.role,
495
+ "agent": _agent_for(record.role),
496
+ "model": _model_for(ctx, record),
497
+ "tier": record.current_tier,
498
+ "skills": list(record.skills),
499
+ "outputs": list(record.allowed_writes),
500
+ "attempt": record.dispatches,
501
+ "tries": record.tries,
502
+ "try": min(state.tries_used + 1, record.tries),
503
+ "tries_left": max(record.tries - state.tries_used, 0),
504
+ "dispatches": state.dispatches,
505
+ "max_dispatches": loop.dispatch_limit(record),
506
+ "previous_label": state.label,
507
+ "tool_calls": queue.tool_calls(record.task_id),
508
+ "tool_call_cap": record.tool_call_cap,
509
+ "prompt": prompt,
510
+ **extra,
511
+ }
512
+ # Add candidate info if this is a best-of-N candidate
513
+ if candidate is not None:
514
+ entry["candidate"] = candidate
515
+ entry["variant"] = variant or ""
516
+ return entry
517
+
518
+
519
+ def _blocked(
520
+ ctx: AppContext,
521
+ graph: BuildGraph,
522
+ queue: TaskQueue,
523
+ summary: RunSummary,
524
+ skip: set[str],
525
+ ) -> list[dict[str, Any]]:
526
+ """Why the build stopped, per instance (from the journal of `summary`'s run).
527
+
528
+ Gates; failed rules with their label (an agent task also with its queue status, and
529
+ the HANDOFF path when its loop stopped); and every rule that depends on one of
530
+ those, with the ones it waits for (`blocked_by`). `skip` holds instances reported
531
+ elsewhere (handed out, in progress, or blocked for a reason of their own): they and
532
+ rules that wait only for them are left out.
533
+ """
534
+ events = journal_mod.read(ctx.layout.journal(summary.run_id)).events
535
+ gates = {
536
+ e.rule_instance: str(e.payload.get("gate", "")) for e in events if e.type == "gate_wait"
537
+ }
538
+ fails = {e.rule_instance: e for e in events if e.type == "rule_fail"}
539
+ handoff = str(_handoff_path(ctx, summary.run_id))
540
+ blocked: list[dict[str, Any]] = []
541
+ reported: list[str] = []
542
+ for iid in summary.waiting_gate:
543
+ blocked.append(
544
+ {"instance": iid, "reason": f"waiting for gate {gates.get(iid, '?')!r} to be approved"}
545
+ )
546
+ reported.append(iid)
547
+ for iid in summary.failed:
548
+ if iid in skip:
549
+ continue
550
+ event = fails.get(iid)
551
+ entry: dict[str, Any] = {
552
+ "instance": iid,
553
+ "reason": (str(event.payload.get("message", "")) if event else "") or "failed",
554
+ "label": event.failure_label if event else None,
555
+ }
556
+ record = queue.get(iid)
557
+ if record is not None:
558
+ entry["status"] = record.status
559
+ if record.status in ("budget_exhausted", "needs_human"):
560
+ entry["handoff"] = handoff
561
+ blocked.append(entry)
562
+ reported.append(iid)
563
+ waits: dict[str, set[str]] = {}
564
+ waiting = set(summary.blocked)
565
+ for iid in reported:
566
+ for dependent in graph.downstream(iid) & waiting:
567
+ waits.setdefault(dependent, set()).add(iid)
568
+ for dependent in sorted(waits):
569
+ on = sorted(waits[dependent])
570
+ blocked.append(
571
+ {
572
+ "instance": dependent,
573
+ "reason": f"depends on {', '.join(on)}, which did not finish",
574
+ "blocked_by": on,
575
+ }
576
+ )
577
+ return blocked
578
+
579
+
580
+ # --- revise: a person rejected a gate with a note (M2-11b) ------------------------------
581
+
582
+
583
+ def _revision_path(ctx: AppContext, task_id: str) -> Path:
584
+ """Where a task's pending revision note is kept (state backend, engine-owned)."""
585
+ return ctx.layout.state_dir / "runtime" / "revisions" / f"{state_key(task_id)}.json"
586
+
587
+
588
+ def _revision(ctx: AppContext, task_id: str) -> dict[str, Any] | None:
589
+ """The person's note the task's next attempts must answer, if any."""
590
+ path = _revision_path(ctx, task_id)
591
+ try:
592
+ data = json.loads(path.read_text(encoding="utf-8"))
593
+ except (OSError, ValueError):
594
+ return None
595
+ return data if isinstance(data, dict) else None
596
+
597
+
598
+ def _revision_text(revision: Mapping[str, Any]) -> str:
599
+ return (
600
+ f"A person ({revision.get('by', '?')}) rejected your last output at gate "
601
+ f"{revision.get('gate', '?')!r} with this note: {revision.get('note', '')} "
602
+ "Revise your outputs so they answer the note, and keep what the note does not ask "
603
+ "to change."
604
+ )
605
+
606
+
607
+ def _latest_decision(ctx: AppContext, gate_id: str, instance: RuleInstance) -> Approval | None:
608
+ """The latest current decision file for `gate_id` over `instance`'s file inputs (the
609
+ rule `GateEvaluator` applies; waivers aside)."""
610
+ refs = [ref for ref in instance.inputs if ref.path is not None]
611
+ current = ctx.store.current_hashes(refs)
612
+ decisions = sorted(
613
+ (
614
+ a
615
+ for a in ctx.review.approvals(gate_id)
616
+ if a.decision != "waive"
617
+ and a.is_current(current)
618
+ and set(current) <= set(a.artifact_hashes)
619
+ ),
620
+ key=lambda a: a.at,
621
+ )
622
+ return decisions[-1] if decisions else None
623
+
624
+
625
+ def _revise_rejected(ctx: AppContext, scheduler: Scheduler, target: str) -> list[dict[str, Any]]:
626
+ """Hand an accepted agent task out again when a person rejected its output, with a note.
627
+
628
+ For each gated instance of `target` whose gate is `rejected` by a decision file (a
629
+ person's `approve(reject=true, note=...)` or `chipgraph approve --reject --note`), when
630
+ every file the gate covers is the output of an accepted agent task that was accepted
631
+ before the rejection: each such task is reopened as a new `ready` task (a new budget
632
+ cycle) and rewound in the build, and the note is kept for its context
633
+ (`previous_rejection`) until it is accepted again. A rejection with no note, of a gate
634
+ over any file no agent writes, or one the task already answered (accepted after it)
635
+ changes nothing: the gate stays rejected for a person to decide.
636
+ """
637
+ graph = scheduler.graph
638
+ gates = scheduler.gates
639
+ if gates is None:
640
+ return []
641
+ queue = _queue(ctx)
642
+ producers: dict[str, str] = {}
643
+ for iid, inst in graph.instances.items():
644
+ if graph.rules[inst.rule_id].kind == "agent":
645
+ for ref in inst.outputs:
646
+ if ref.path is not None:
647
+ producers[ref.path] = iid
648
+ revised: list[dict[str, Any]] = []
649
+ for iid in sorted(graph.select(target)):
650
+ instance = graph.instances[iid]
651
+ rule = graph.rules[instance.rule_id]
652
+ if not rule.gate:
653
+ continue
654
+ gate_id = gate_id_for(rule.gate, instance.params)
655
+ if gates.status(gate_id, instance) != "rejected":
656
+ continue
657
+ decision = _latest_decision(ctx, gate_id, instance)
658
+ if decision is None or decision.decision != "reject" or not decision.note.strip():
659
+ continue
660
+ covered = [ref.path for ref in instance.inputs if ref.path is not None]
661
+ owners = {producers.get(path) for path in covered}
662
+ if not covered or None in owners:
663
+ continue
664
+ for task_id in sorted(o for o in owners if o is not None):
665
+ record = queue.get(task_id)
666
+ if record is None or record.status != "accepted" or record.updated_at >= decision.at:
667
+ continue
668
+ task_rule, task_instance = _instance(graph, task_id)
669
+ # `enqueue` reopens an accepted task whose outputs no longer hash as accepted:
670
+ # the person's rejection says they no longer hold.
671
+ queue.enqueue(
672
+ task_for(task_rule, task_instance),
673
+ inputs_hash=instance_inputs_hash(ctx.store, task_instance),
674
+ output_hashes={"<rejected>": decision.at.isoformat()},
675
+ tool_call_cap=record.tool_call_cap,
676
+ max_dispatches=record.max_dispatches,
677
+ )
678
+ scheduler.rewind(task_id)
679
+ item = {
680
+ "task_id": task_id,
681
+ "gate": gate_id,
682
+ "by": decision.by,
683
+ "at": decision.at.isoformat(),
684
+ "note": decision.note,
685
+ }
686
+ path = _revision_path(ctx, task_id)
687
+ path.parent.mkdir(parents=True, exist_ok=True)
688
+ path.write_text(json.dumps({"schema_version": 1, **item}, indent=2), encoding="utf-8")
689
+ revised.append(item)
690
+ return revised
691
+
692
+
693
+ # --- Best-of-N dispatch helpers ---------------------------------------------------------
694
+
695
+
696
+ def _dispatch_candidates(
697
+ ctx: AppContext,
698
+ queue: TaskQueue,
699
+ graph: BuildGraph,
700
+ base_record: AgentTaskRecord,
701
+ rule: RuleSpec,
702
+ instance: RuleInstance,
703
+ run_id: str,
704
+ baseline: dict[str, str],
705
+ n: int,
706
+ ) -> tuple[BestOfNRound, list[tuple[AgentTaskRecord, int, str]]]:
707
+ """Dispatch N candidates for a best-of-N round.
708
+
709
+ Returns the round state and list of (record, candidate_num, variant) tuples.
710
+ """
711
+ profile = ctx.require_profile().profile
712
+ vcs = ctx.registry.get("vcs", "git")
713
+ inputs = input_paths_for(instance)
714
+
715
+ # Plan the budget
716
+ budget_plan = plan_budget(
717
+ rule.budget,
718
+ requested=n,
719
+ cfg=profile.select,
720
+ max_cost=None, # No cost cap in this runtime
721
+ variants=len(VARIANTS),
722
+ )
723
+ actual_n = budget_plan.n
724
+
725
+ # Compute round number: base record's attempts counts failed rounds, +1 for current
726
+ # A failed round increments attempts by 1, so round_num = attempts + 1
727
+ round_num = base_record.attempts + 1
728
+
729
+ # Get variant hints
730
+ variant_ids = list(VARIANTS.keys())[:actual_n]
731
+ variant_hints = [VARIANTS[vid] for vid in variant_ids]
732
+
733
+ # Create candidates
734
+ candidates: list[CandidateState] = []
735
+ dispatched: list[tuple[AgentTaskRecord, int, str]] = []
736
+
737
+ for i, (_vid, variant) in enumerate(zip(variant_ids, variant_hints, strict=False), 1):
738
+ cand_task_id = candidate_id(base_record.task_id, i, round_num)
739
+
740
+ # Create workspace for this candidate
741
+ try:
742
+ ws, seed_hashes = create_workspace(
743
+ ctx, vcs, run_id, cand_task_id, inputs, tuple(base_record.allowed_writes)
744
+ )
745
+ except Exception as exc:
746
+ # If workspace creation fails, clean up and raise
747
+ for _, cand_num, _ in dispatched:
748
+ cand_id = candidate_id(base_record.task_id, cand_num, round_num)
749
+ cand_rec = queue.get(cand_id)
750
+ if cand_rec and cand_rec.workspace:
751
+ with contextlib.suppress(Exception):
752
+ remove_workspace(ctx, vcs, cand_rec.workspace)
753
+ raise AppError(f"failed to create workspace for candidate {i}: {exc}") from exc
754
+
755
+ # Dispatch the candidate
756
+ try:
757
+ cand_record = queue.dispatch(
758
+ cand_task_id,
759
+ run_id=run_id,
760
+ baseline=baseline,
761
+ denied_reads=_denied_reads(ctx, graph, base_record),
762
+ workspace=str(ws),
763
+ seed_hashes=seed_hashes,
764
+ )
765
+ except QueueError:
766
+ # The candidate is a record of the same instance under its own id
767
+ from chipgraph.core.plugin_api.types import AgentTask
768
+
769
+ cand_task = AgentTask(
770
+ instance=instance,
771
+ role=base_record.role,
772
+ skills=base_record.skills,
773
+ allowed_writes=base_record.allowed_writes,
774
+ budget=budget_plan.slice,
775
+ )
776
+ queue.enqueue(
777
+ cand_task,
778
+ inputs_hash=base_record.inputs_hash,
779
+ output_hashes={},
780
+ tool_call_cap=base_record.tool_call_cap,
781
+ max_dispatches=loop.max_dispatches(budget_plan.slice.tries),
782
+ task_id=cand_task_id,
783
+ )
784
+ cand_record = queue.dispatch(
785
+ cand_task_id,
786
+ run_id=run_id,
787
+ baseline=baseline,
788
+ denied_reads=_denied_reads(ctx, graph, base_record),
789
+ workspace=str(ws),
790
+ seed_hashes=seed_hashes,
791
+ )
792
+
793
+ candidates.append(
794
+ CandidateState(
795
+ candidate=i,
796
+ variant=variant,
797
+ task_id=cand_task_id,
798
+ workspace=str(ws),
799
+ status="dispatched",
800
+ )
801
+ )
802
+ dispatched.append((cand_record, i, variant))
803
+
804
+ round_state = BestOfNRound(
805
+ base_task_id=base_record.task_id,
806
+ run_id=run_id,
807
+ round_num=round_num,
808
+ n=actual_n,
809
+ candidates=tuple(candidates),
810
+ ledger_tries=budget_plan.limits.tries,
811
+ ledger_used=0,
812
+ status="running",
813
+ )
814
+
815
+ return round_state, dispatched
816
+
817
+
818
+ # --- next_task --------------------------------------------------------------------------
819
+
820
+
821
+ async def next_task(ctx: AppContext, target: str = "*") -> dict[str, Any]:
822
+ """Run the build for `target` and hand out every agent task it reached.
823
+
824
+ Returns `tasks` (newly dispatched: each goes to its own subagent, in parallel),
825
+ `in_progress` (dispatched earlier and not submitted yet), `blocked` (gates, people,
826
+ failures, stopped tasks with their label and HANDOFF path, and the rules that
827
+ depend on them), and either `done: true` (the build finished) or, with nothing to
828
+ run, `stopped: true`, `waiting` (why) and `handoff` (this run's HANDOFF.md).
829
+
830
+ A task is never handed out more than its `max_dispatches` (tries plus infra
831
+ retries) in one budget cycle, and never once it is `budget_exhausted` or
832
+ `needs_human`.
833
+
834
+ At the start, sweeps stale workspaces left by killed runs (a process that started
835
+ workspaces and died without cleaning up).
836
+ """
837
+ ctx.require_profile()
838
+ _require_runtime(ctx)
839
+
840
+ # Sweep stale workspaces from killed runs
841
+ try:
842
+ vcs = ctx.registry.get("vcs", "git")
843
+ sweep_workspaces(ctx, vcs)
844
+ except Exception:
845
+ pass # Best effort; don't block the build
846
+
847
+ scheduler = make_scheduler(ctx, target)
848
+ _check_skills(ctx, scheduler.graph)
849
+ revised = _revise_rejected(ctx, scheduler, target)
850
+ summary = await scheduler.run(target)
851
+ graph = scheduler.graph
852
+ queue = _queue(ctx)
853
+ for item in revised:
854
+ _emit(ctx, summary.run_id, item["task_id"], "agent_turn", {"phase": "revise", **item})
855
+
856
+ agent_ids = [
857
+ iid
858
+ for iid in sorted(graph.select(target))
859
+ if graph.rules[graph.instances[iid].rule_id].kind == "agent"
860
+ ]
861
+ now: dict[str, str] | None = None
862
+ dispatched: list[AgentTaskRecord] = []
863
+ in_progress: list[AgentTaskRecord] = []
864
+ held: list[dict[str, Any]] = []
865
+ for iid in agent_ids:
866
+ record = queue.get(iid)
867
+ if record is None:
868
+ continue
869
+ if record.status in ACTIVE_STATUSES:
870
+ in_progress.append(record)
871
+ continue
872
+ # Only tasks this build actually reached (their executor ran and said "waiting").
873
+ if record.status not in OPEN_STATUSES or iid not in summary.failed:
874
+ continue
875
+ limit = loop.dispatch_limit(record)
876
+ if record.state.dispatches >= limit: # the hard bound; a live loop stops before
877
+ held.append(
878
+ {
879
+ "instance": iid,
880
+ "reason": (
881
+ f"handed out {record.state.dispatches} times, its limit (tries plus "
882
+ "infra retries); a person must look at it"
883
+ ),
884
+ "label": record.state.label,
885
+ "status": record.status,
886
+ }
887
+ )
888
+ continue
889
+ if now is None:
890
+ now = await snapshot(ctx)
891
+ if record.outside:
892
+ before = queue.baseline(iid)
893
+ left = [p for p in record.outside if before.get(p) != now.get(p)]
894
+ if left:
895
+ held.append(
896
+ {
897
+ "instance": iid,
898
+ "reason": (
899
+ "the last attempt changed files outside the task's outputs; "
900
+ f"revert them before it runs again: {', '.join(left)}"
901
+ ),
902
+ }
903
+ )
904
+ continue
905
+ # A role without a read tool cannot overwrite a file: move its outputs aside
906
+ # first, so each `Write` creates one (`previous`); put them back if the
907
+ # dispatch fails here.
908
+ mover = PreviousOutputs(queue, iid, ctx.root)
909
+ token: MoveToken | None = None
910
+ if moves_outputs(find_role(record.role)):
911
+ token = mover.move_aside(record.allowed_writes, dispatch=record.dispatches + 1)
912
+ moved = [m.path for m in token.moved] if token is not None else []
913
+ for path in moved:
914
+ now.pop(path, None)
915
+
916
+ # Isolation: create workspace if fanout.isolate is true
917
+ workspace_path: str | None = None
918
+ seed_hashes: dict[str, str] = {}
919
+ profile = ctx.require_profile().profile
920
+ rule, instance = _instance(graph, iid)
921
+ # A reviewing role (the Critic) writes nothing: the engine records its report in the
922
+ # user's tree. There is nothing to isolate, and it reads the project as it is.
923
+ isolated = should_isolate(profile, rule) and not review_mod.is_review_role(
924
+ find_role(record.role)
925
+ )
926
+
927
+ # F2: check overlapping write sets with tasks being dispatched this cycle
928
+ # (only for isolated tasks - non-isolated tasks write to user's tree directly)
929
+ if isolated:
930
+ # Check max_parallel limit (only for isolated tasks with workspaces)
931
+ max_par = max_parallel_limit(profile)
932
+ live_workspaces = len([r for r in (*dispatched, *in_progress) if r.workspace])
933
+ if live_workspaces >= max_par:
934
+ if token is not None:
935
+ mover.undo(token)
936
+ held.append(
937
+ {
938
+ "instance": iid,
939
+ "reason": (
940
+ f"max_parallel limit reached ({max_par} workspaces); "
941
+ "waiting for a workspace to finish"
942
+ ),
943
+ }
944
+ )
945
+ continue
946
+
947
+ # Check for overlapping write sets with tasks already being dispatched
948
+ dispatching_writes = {r.task_id: r.allowed_writes for r in dispatched if r.workspace}
949
+ dispatching_writes[iid] = record.allowed_writes
950
+ overlaps = check_overlapping_writes(dispatching_writes)
951
+ if overlaps and any(iid in tasks for tasks in overlaps.values()):
952
+ if token is not None:
953
+ mover.undo(token)
954
+ # Find which paths overlap
955
+ overlap_info = [
956
+ f"{path}: {', '.join(tasks)}"
957
+ for path, tasks in overlaps.items()
958
+ if iid in tasks
959
+ ]
960
+ held.append(
961
+ {
962
+ "instance": iid,
963
+ "reason": (
964
+ f"write set overlaps with another task being dispatched: "
965
+ f"{'; '.join(overlap_info)}"
966
+ ),
967
+ }
968
+ )
969
+ continue
970
+
971
+ if isolated:
972
+ try:
973
+ vcs = ctx.registry.get("vcs", "git")
974
+ inputs = input_paths_for(instance)
975
+
976
+ # Check for best-of-N opt-in
977
+ requested_n = best_of_n(profile, rule.id, dict(instance.params))
978
+ if requested_n is not None and requested_n >= 2:
979
+ # Best-of-N: dispatch multiple candidates
980
+ try:
981
+ round_state, cand_dispatched = _dispatch_candidates(
982
+ ctx,
983
+ queue,
984
+ graph,
985
+ record,
986
+ rule,
987
+ instance,
988
+ summary.run_id,
989
+ now,
990
+ requested_n,
991
+ )
992
+ _save_best_of_round(ctx, round_state)
993
+
994
+ # Add all candidates to dispatched and emit events
995
+ for cand_record, cand_num, variant in cand_dispatched:
996
+ payload = {
997
+ "phase": "dispatch",
998
+ "attempt": cand_record.dispatches,
999
+ "try": cand_record.state.tries_used + 1,
1000
+ "tier": cand_record.current_tier,
1001
+ "outputs": list(cand_record.allowed_writes),
1002
+ "agent": _agent_for(cand_record.role),
1003
+ "model": _model_for(ctx, cand_record),
1004
+ "denied_reads": list(cand_record.denied_reads),
1005
+ "workspace": cand_record.workspace,
1006
+ "best_of": {
1007
+ "base_task": iid,
1008
+ "candidate": cand_num,
1009
+ "n": round_state.n,
1010
+ "round": round_state.round_num,
1011
+ "variant": variant,
1012
+ },
1013
+ }
1014
+ _emit(
1015
+ ctx, cand_record.run_id, cand_record.task_id, "agent_turn", payload
1016
+ )
1017
+ dispatched.append(cand_record)
1018
+ continue
1019
+
1020
+ except AppError as exc:
1021
+ if token is not None:
1022
+ mover.undo(token)
1023
+ held.append(
1024
+ {"instance": iid, "reason": f"best-of-N dispatch failed: {exc}"}
1025
+ )
1026
+ continue
1027
+
1028
+ # Regular isolated dispatch (best-of-1 or not opted in)
1029
+ ws, seed_hashes = create_workspace(
1030
+ ctx, vcs, summary.run_id, iid, inputs, tuple(record.allowed_writes)
1031
+ )
1032
+ workspace_path = str(ws)
1033
+ except Exception as exc:
1034
+ if token is not None:
1035
+ mover.undo(token)
1036
+ held.append(
1037
+ {
1038
+ "instance": iid,
1039
+ "reason": f"failed to create isolated workspace: {exc}",
1040
+ }
1041
+ )
1042
+ continue
1043
+
1044
+ try:
1045
+ record = queue.dispatch(
1046
+ iid,
1047
+ run_id=summary.run_id,
1048
+ baseline=now,
1049
+ denied_reads=_denied_reads(ctx, graph, record),
1050
+ workspace=workspace_path,
1051
+ seed_hashes=seed_hashes,
1052
+ )
1053
+ except BaseException:
1054
+ if token is not None:
1055
+ mover.undo(token)
1056
+ if workspace_path:
1057
+ try:
1058
+ vcs = ctx.registry.get("vcs", "git")
1059
+ remove_workspace(ctx, vcs, workspace_path)
1060
+ except Exception:
1061
+ pass
1062
+ raise
1063
+ payload = {
1064
+ "phase": "dispatch",
1065
+ "attempt": record.dispatches,
1066
+ "try": record.state.tries_used + 1,
1067
+ "tier": record.current_tier,
1068
+ "outputs": list(record.allowed_writes),
1069
+ "agent": _agent_for(record.role),
1070
+ "model": _model_for(ctx, record),
1071
+ "denied_reads": list(record.denied_reads),
1072
+ }
1073
+ if moved:
1074
+ payload["moved_aside"] = moved
1075
+ if workspace_path:
1076
+ payload["workspace"] = workspace_path
1077
+ _emit(ctx, record.run_id, iid, "agent_turn", payload)
1078
+ dispatched.append(record)
1079
+
1080
+ skip = {r.task_id for r in (*dispatched, *in_progress)} | {h["instance"] for h in held}
1081
+ blocked = held + _blocked(ctx, graph, queue, summary, skip)
1082
+
1083
+ # Build task entries, including candidate info for best-of-N tasks
1084
+ def _build_task_entry(r: AgentTaskRecord) -> dict[str, Any]:
1085
+ if is_candidate_id(r.task_id):
1086
+ base_task_id, cand_num, _round_num = parse_candidate_id(r.task_id)
1087
+ round_state = _load_best_of_round(ctx, base_task_id)
1088
+ if round_state:
1089
+ cand_state = next(
1090
+ (c for c in round_state.candidates if c.candidate == cand_num), None
1091
+ )
1092
+ if cand_state:
1093
+ return _task_entry(
1094
+ ctx, queue, r, candidate=cand_num, variant=cand_state.variant
1095
+ )
1096
+ return _task_entry(ctx, queue, r)
1097
+
1098
+ answer: dict[str, Any] = {
1099
+ "run_id": summary.run_id,
1100
+ "tasks": [_build_task_entry(r) for r in dispatched],
1101
+ "in_progress": [_build_task_entry(r) for r in in_progress],
1102
+ "done": False,
1103
+ "stopped": False,
1104
+ "waiting": None,
1105
+ "blocked": blocked,
1106
+ "handoff": None,
1107
+ }
1108
+ if revised:
1109
+ answer["revised"] = revised
1110
+ if dispatched or in_progress:
1111
+ return answer
1112
+ if summary.ok and not blocked:
1113
+ answer["done"] = True
1114
+ return answer
1115
+ answer["stopped"] = True
1116
+ answer["handoff"] = str(_handoff_path(ctx, summary.run_id))
1117
+ answer["waiting"] = "; ".join(f"{b['instance']}: {b['reason']}" for b in blocked) or (
1118
+ f"{len(summary.blocked)} rule instance(s) blocked"
1119
+ )
1120
+ return answer
1121
+
1122
+
1123
+ # --- get_context ------------------------------------------------------------------------
1124
+
1125
+
1126
+ def _is_nda(ctx: AppContext, path: str, label: str) -> bool:
1127
+ return label == "nda" or ctx.store.labels.label_for(path) == "nda"
1128
+
1129
+
1130
+ def _model_input(ctx: AppContext, key: str) -> dict[str, Any]:
1131
+ entry: dict[str, Any] = {"model_key": key}
1132
+ kind, _, name = key.partition("/")
1133
+ db = default_model_db_path(ctx.layout.root)
1134
+ if kind != "block" or not name or not db.is_file():
1135
+ entry["note"] = "not available from the Design Model (run `chipgraph ingest` first)"
1136
+ return entry
1137
+ store = ModelStore(db)
1138
+ try:
1139
+ info = ModelQuery(store.read(), store).block(name)
1140
+ except QueryError as exc:
1141
+ entry["note"] = str(exc)
1142
+ return entry
1143
+ entry["content"] = json.dumps(info.model_dump(mode="json"), indent=1, sort_keys=True)
1144
+ return entry
1145
+
1146
+
1147
+ def _refuse(
1148
+ ctx: AppContext,
1149
+ queue: TaskQueue,
1150
+ record: AgentTaskRecord,
1151
+ question: str,
1152
+ payload: Mapping[str, Any],
1153
+ ) -> AppError:
1154
+ """Hand the task to a person with `question`; the error for the tool call."""
1155
+ stopped = queue.needs_human(
1156
+ record.task_id, result=AgentResult(status="needs_human", open_questions=(question,))
1157
+ )
1158
+ restored = _settle(ctx, queue, stopped)
1159
+ extra = {"restored": restored} if restored else {}
1160
+ _emit(
1161
+ ctx,
1162
+ record.run_id,
1163
+ record.task_id,
1164
+ "agent_turn",
1165
+ {"phase": "context_refused", **payload, **extra},
1166
+ )
1167
+ return AppError(question)
1168
+
1169
+
1170
+ def _settle(ctx: AppContext, queue: TaskQueue, record: AgentTaskRecord) -> list[str]:
1171
+ """Close the task's moved-outputs cycle once it ended (`previous`), and clean up workspace.
1172
+
1173
+ Accepted: the new files stay, workspace is removed (outputs were copied back already).
1174
+ `budget_exhausted` or `needs_human`: every file that existed before the task is put
1175
+ back, workspace is removed (nothing copied back). Returns the paths put back.
1176
+
1177
+ Every ending (accept, reject-and-redispatch, budget_exhausted, needs_human) removes
1178
+ the workspace. A rejected task that will be re-dispatched gets a fresh workspace.
1179
+ """
1180
+ mover = PreviousOutputs(queue, record.task_id, ctx.root)
1181
+ restored: list[str] = []
1182
+ if record.status == "accepted":
1183
+ mover.accept()
1184
+ elif record.status in ("budget_exhausted", "needs_human"):
1185
+ restored = mover.restore(dispatch=record.dispatches)
1186
+
1187
+ # Clean up workspace for any terminal status (accepted, budget_exhausted, needs_human)
1188
+ # For rejected tasks, the workspace is removed too; next_task creates a fresh one
1189
+ terminal_statuses = ("accepted", "budget_exhausted", "needs_human", "rejected")
1190
+ if record.workspace and record.status in terminal_statuses:
1191
+ try:
1192
+ vcs = ctx.registry.get("vcs", "git")
1193
+ remove_workspace(ctx, vcs, record.workspace)
1194
+ except Exception:
1195
+ pass # Best effort cleanup; the sweep will get it later
1196
+
1197
+ return restored
1198
+
1199
+
1200
+ def _unseeable(role: RoleSpec | None, record: AgentTaskRecord, instance: RuleInstance) -> list[str]:
1201
+ """Inputs the task's role must not see: of a denied kind (by kind or by name), or on a
1202
+ denied path."""
1203
+ if role is None or role.read_policy.mode != "deny":
1204
+ return []
1205
+ denied = record.denied_reads
1206
+ return sorted(
1207
+ ref.path or str(ref.model_key)
1208
+ for ref in instance.inputs
1209
+ if denied_kind(role, ref.kind, ref.path) is not None
1210
+ or (ref.path is not None and path_denied(ref.path, denied))
1211
+ )
1212
+
1213
+
1214
+ def _denies(role: RoleSpec | None, record: AgentTaskRecord) -> Callable[[str], bool]:
1215
+ """Whether the task's role must not read a repo-relative path: one of its
1216
+ `denied_reads`, or a file its read policy denies by kind or by name."""
1217
+
1218
+ def denied(path: str) -> bool:
1219
+ if path_denied(path, record.denied_reads):
1220
+ return True
1221
+ return role is not None and denied_kind(role, kind_for(path), path) is not None
1222
+
1223
+ return denied
1224
+
1225
+
1226
+ def _instructions(role: RoleSpec | None, rule: RuleSpec, skills: tuple[SkillSpec, ...]) -> str:
1227
+ if review_mod.is_review_role(role):
1228
+ follow = " Follow the instructions in `skill_texts`." if skills else ""
1229
+ return (
1230
+ "Review the change in `review.diff` against `review.spec` and `review.model`. "
1231
+ "You write no file and have no shell: reply with one JSON object that follows "
1232
+ "`review.reply_schema`, with `target`, `base` and `head` as in `review`. Every "
1233
+ "comment is on a file:line inside the diff and quotes its evidence as "
1234
+ f"'path:line text'; no comments means no findings.{follow} On submit the engine "
1235
+ "checks the review against this diff and writes it to `outputs[0]`; an invalid "
1236
+ "review is rejected and uses a try."
1237
+ )
1238
+ reading = (
1239
+ "You may read other project files for style."
1240
+ if role is None or role.can_read_files
1241
+ else "You have no file-reading tools, on purpose: use only this context."
1242
+ )
1243
+ follow = " Follow the instructions in `skill_texts`." if skills else ""
1244
+ return (
1245
+ "Write only the files in `outputs` (absolute paths in `outputs_abs`); the "
1246
+ f"chipgraph guard refuses any other write, and you have no shell. {reading}{follow} "
1247
+ "On submit the engine checks that only the outputs changed, that they exist, and "
1248
+ f"runs these checks: {', '.join(rule.checks) or 'none'}. "
1249
+ "Finish with a short report: files written, assumptions, open questions."
1250
+ )
1251
+
1252
+
1253
+ def _guarded(
1254
+ ctx: AppContext,
1255
+ role: RoleSpec | None,
1256
+ graph: BuildGraph,
1257
+ instance: RuleInstance,
1258
+ record: AgentTaskRecord,
1259
+ ) -> GuardedContext | RtlAuthorContext | None:
1260
+ """The guarded context of a task whose role must not see some kinds, else None: the
1261
+ testbench Author's (no RTL), or the RTL Author's (no testbench, M2-04)."""
1262
+ return guarded_context(ctx, role, instance, list(graph.rules.values()), _denies(role, record))
1263
+
1264
+
1265
+ def _feedback_filter(ctx: AppContext, record: AgentTaskRecord) -> FeedbackFilter | None:
1266
+ """What `submit` may show the task's role of a failed check (None: everything)."""
1267
+ return FeedbackFilter.for_role(
1268
+ record.role, denied=record.denied_reads, root=str(ctx.root.resolve())
1269
+ )
1270
+
1271
+
1272
+ def _previous_rejection(ctx: AppContext, record: AgentTaskRecord) -> list[str]:
1273
+ """Why the last attempt was rejected: a person's note from a rejected gate (until the
1274
+ task is accepted again), then the redo instruction (it names the label and each
1275
+ failure as `file:line`), or the bare reasons on a record from before it."""
1276
+ revision = _revision(ctx, record.task_id)
1277
+ head = [_revision_text(revision)] if revision is not None else []
1278
+ state = record.budget_state
1279
+ if state is not None and state.redo:
1280
+ return [*head, state.redo]
1281
+ return [*head, *record.reasons]
1282
+
1283
+
1284
+ async def get_context(ctx: AppContext, task_id: str) -> dict[str, Any]:
1285
+ """The context of one dispatched task: inputs as text, outputs, role, skills.
1286
+
1287
+ Refused, with no content, when any input or output is labelled `nda`: every model
1288
+ this runtime uses is a cloud model. Refused too when an input is of a kind the task's
1289
+ role must not see (its `read_policy`), or on one of its `denied_reads`. Either way the
1290
+ task then waits for a person (`needs_human`).
1291
+ """
1292
+ ctx.require_profile()
1293
+ _require_runtime(ctx)
1294
+ queue = _queue(ctx)
1295
+ record = _record(queue, task_id)
1296
+ if record.status != "dispatched":
1297
+ raise AppError(f"task {task_id!r} is {record.status}, not dispatched; call next_task")
1298
+ graph = _graph(ctx)
1299
+ rule, instance = _instance(graph, task_id)
1300
+
1301
+ nda = sorted(
1302
+ ref.path
1303
+ for ref in (*instance.inputs, *instance.outputs)
1304
+ if ref.path is not None and _is_nda(ctx, ref.path, ref.label)
1305
+ )
1306
+ if nda:
1307
+ question = (
1308
+ f"task {task_id!r} reads or writes data labelled 'nda' ({', '.join(nda)}); "
1309
+ "runtime claude-code sends context to a cloud model, so it is refused. Run the "
1310
+ "task with a local runtime, or have a person do it."
1311
+ )
1312
+ raise _refuse(ctx, queue, record, question, {"nda": nda})
1313
+
1314
+ role = find_role(record.role)
1315
+ unseeable = _unseeable(role, record, instance)
1316
+ if role is not None and unseeable:
1317
+ kinds = ", ".join(role.read_policy.deny_kinds) or "denied"
1318
+ question = (
1319
+ f"task {task_id!r} gives role {role.id!r} inputs it must not see "
1320
+ f"({kinds} artifacts or paths): {', '.join(unseeable)}; it is refused. Remove "
1321
+ "those inputs from the rule, or give the task to another role or a person."
1322
+ )
1323
+ raise _refuse(ctx, queue, record, question, {"denied_inputs": unseeable})
1324
+ try:
1325
+ skills = _skill_set(ctx).resolve(record.skills, record.role)
1326
+ except SkillError as exc:
1327
+ raise AppError(f"task {task_id!r}: {exc}") from exc
1328
+
1329
+ guarded = _guarded(ctx, role, graph, instance, record)
1330
+ extra_specs: list[str] = []
1331
+ if guarded is not None:
1332
+ listed = {ref.path for ref in instance.inputs}
1333
+ extra_specs = [
1334
+ p
1335
+ for p in guarded.spec_documents()
1336
+ if p not in listed and not path_denied(p, record.denied_reads)
1337
+ ]
1338
+ nda_specs = [p for p in extra_specs if _is_nda(ctx, p, "internal")]
1339
+ if nda_specs:
1340
+ question = (
1341
+ f"task {task_id!r} would read the spec labelled 'nda' "
1342
+ f"({', '.join(nda_specs)}); runtime claude-code sends context to a cloud "
1343
+ "model, so it is refused. Run the task with a local runtime, or have a "
1344
+ "person do it."
1345
+ )
1346
+ raise _refuse(ctx, queue, record, question, {"nda": nda_specs})
1347
+ inputs: list[dict[str, Any]] = []
1348
+ for ref in instance.inputs:
1349
+ if ref.path is None:
1350
+ key = str(ref.model_key)
1351
+ inputs.append(guarded.model_input(key) if guarded else _model_input(ctx, key))
1352
+ continue
1353
+ entry: dict[str, Any] = {"path": ref.path, "kind": ref.kind}
1354
+ fragment = graph.spec_fragments.get(f"{ref.repo}:{ref.path}")
1355
+ if fragment:
1356
+ entry["fragment"] = fragment
1357
+ section = guarded.covers(ref.path) if guarded is not None else None
1358
+ if section is not None: # shown in a section of the context already
1359
+ entry["section"] = section
1360
+ elif ctx.store.exists(ref):
1361
+ text = (ctx.root / ref.path).read_text(encoding="utf-8", errors="replace")
1362
+ if len(text) > _MAX_INPUT_CHARS:
1363
+ text = text[:_MAX_INPUT_CHARS]
1364
+ entry["truncated"] = True
1365
+ entry["content"] = text
1366
+ else:
1367
+ entry["missing"] = True
1368
+ inputs.append(entry)
1369
+ for rel in extra_specs: # a guarded task's spec documents, from the profile layout
1370
+ text = (ctx.root / rel).read_text(encoding="utf-8", errors="replace")
1371
+ entry = {"path": rel, "kind": "spec", "from": "layout"}
1372
+ if len(text) > _MAX_INPUT_CHARS:
1373
+ text = text[:_MAX_INPUT_CHARS]
1374
+ entry["truncated"] = True
1375
+ entry["content"] = text
1376
+ inputs.append(entry)
1377
+
1378
+ review: dict[str, Any] | None = None
1379
+ if review_mod.is_review_role(role):
1380
+ scope, review = await review_mod.build_review(ctx, graph, rule, instance)
1381
+ nda_diff = sorted(p for p in scope.files if _is_nda(ctx, p, "public"))
1382
+ if nda_diff:
1383
+ question = (
1384
+ f"task {task_id!r} reviews files labelled 'nda' ({', '.join(nda_diff)}); "
1385
+ "runtime claude-code sends context to a cloud model, so it is refused. Run "
1386
+ "the review with a local runtime, or have a person do it."
1387
+ )
1388
+ raise _refuse(ctx, queue, record, question, {"nda": nda_diff})
1389
+ review_mod.save_scope(ctx, task_id, record.dispatches, scope)
1390
+
1391
+ _emit(
1392
+ ctx,
1393
+ record.run_id,
1394
+ task_id,
1395
+ "agent_turn",
1396
+ {"phase": "context", "attempt": record.dispatches},
1397
+ )
1398
+ root = ctx.root.resolve()
1399
+ # Determine the effective root for this task: workspace if isolated, else project root
1400
+ workspace = record.workspace
1401
+ if workspace:
1402
+ effective_root = Path(workspace)
1403
+ workspace_note = (
1404
+ " This task runs in an isolated workspace. The workspace IS the project for "
1405
+ "this task: write your outputs to the absolute paths in `outputs_abs`. The "
1406
+ "workspace is a copy of the project at HEAD with the task's inputs seeded in."
1407
+ )
1408
+ else:
1409
+ effective_root = root
1410
+ workspace_note = ""
1411
+
1412
+ answer: dict[str, Any] = {
1413
+ "task_id": task_id,
1414
+ "rule": rule.id,
1415
+ "description": rule.description,
1416
+ "role": record.role,
1417
+ "skills": list(record.skills),
1418
+ "skill_texts": {skill.id: skill.text for skill in skills},
1419
+ "params": dict(instance.params),
1420
+ "project_root": str(effective_root),
1421
+ "outputs": list(record.allowed_writes),
1422
+ "outputs_abs": [str(effective_root / p) for p in record.allowed_writes],
1423
+ "checks": list(rule.checks),
1424
+ "attempt": record.dispatches,
1425
+ "tries": record.tries,
1426
+ "previous_rejection": _previous_rejection(ctx, record),
1427
+ "previous_label": record.state.label,
1428
+ "inputs": inputs,
1429
+ "instructions": _instructions(role, rule, skills) + workspace_note,
1430
+ }
1431
+ if workspace:
1432
+ answer["workspace"] = workspace
1433
+ answer["isolated"] = True
1434
+ if review is not None:
1435
+ answer["review"] = review
1436
+ if guarded is not None:
1437
+ answer.update(guarded.sections())
1438
+ answer["instructions"] += guarded.instructions
1439
+ if moves_outputs(role):
1440
+ shown, withheld = PreviousOutputs(queue, task_id, ctx.root).context(
1441
+ record.allowed_writes, _denies(role, record)
1442
+ )
1443
+ answer["previous_outputs"] = shown
1444
+ answer["instructions"] += _previous_instructions(shown, withheld)
1445
+ if record.role == "planner":
1446
+ answer["plan"] = _planner_context(ctx, graph, instance)
1447
+ return answer
1448
+
1449
+
1450
+ def _previous_instructions(shown: list[dict[str, Any]], withheld: list[str]) -> str:
1451
+ """What `get_context` tells an author without a read tool about its outputs."""
1452
+ text = (
1453
+ " Every output that existed was moved aside when this task was handed out, so "
1454
+ "each output is a new file: write it whole with Write (Edit has no file to change)."
1455
+ )
1456
+ if any("content" in entry for entry in shown):
1457
+ text += (
1458
+ " Your previous attempt is in `previous_outputs`; write the whole corrected "
1459
+ "file with Write."
1460
+ )
1461
+ if any("existing_output" in entry for entry in shown):
1462
+ text += (
1463
+ " A file that existed before this task is in `previous_outputs` under "
1464
+ "`existing_output`; it is put back if the task is not accepted."
1465
+ )
1466
+ if withheld:
1467
+ text += (
1468
+ f" {len(withheld)} moved file(s) are of a kind your role must not see; "
1469
+ "`previous_outputs` lists them without their text."
1470
+ )
1471
+ return text
1472
+
1473
+
1474
+ def _planner_context(ctx: AppContext, graph: BuildGraph, instance: RuleInstance) -> dict[str, Any]:
1475
+ """A planner task's extra context (M2-03): the block's model slice, the paths it may
1476
+ write, the limits, the naming rule and the plan schema (`digital-rtl` pack)."""
1477
+ from chipgraph.packs.digital_rtl.plan.context import planner_context
1478
+
1479
+ block = instance.params.get("block")
1480
+ if block is None or not instance.outputs or instance.outputs[0].path is None:
1481
+ return {}
1482
+ return planner_context(
1483
+ ctx.root,
1484
+ ctx.require_profile(),
1485
+ list(graph.rules.values()),
1486
+ block,
1487
+ plan_path=instance.outputs[0].path,
1488
+ )
1489
+
1490
+
1491
+ # --- submit -----------------------------------------------------------------------------
1492
+
1493
+
1494
+ def _check_reason(result: CheckResult) -> str:
1495
+ msgs = "; ".join(i.msg for i in result.issues[:5]) or result.log_tail[-500:]
1496
+ return f"check {result.check_id} {result.status}: {msgs}".strip()
1497
+
1498
+
1499
+ async def _take_review(
1500
+ ctx: AppContext,
1501
+ graph: BuildGraph,
1502
+ rule: RuleSpec,
1503
+ instance: RuleInstance,
1504
+ record: AgentTaskRecord,
1505
+ report: SubmitReport,
1506
+ ) -> tuple[ReviewReport | None, list[str]]:
1507
+ """Validate a review task's reply and write it as the task's output (the engine does).
1508
+
1509
+ Returns the review, or `None` and the reasons it is refused (nothing is written).
1510
+ """
1511
+ if report.review is None:
1512
+ return None, [
1513
+ "review: a review task is submitted with `review`, the subagent's JSON review "
1514
+ "(see review.reply_schema in its context)"
1515
+ ]
1516
+ parsed, problems = parse_review(report.review)
1517
+ if parsed is not None:
1518
+ scope = review_mod.load_scope(ctx, record.task_id, record.dispatches)
1519
+ if scope is None: # get_context was not called for this dispatch
1520
+ scope = await review_mod.review_scope(ctx, graph, rule, instance)
1521
+ problems = review_problems(parsed, scope)
1522
+ if parsed is None or problems:
1523
+ return None, [f"review: {p}" for p in problems]
1524
+ out = ctx.root / record.allowed_writes[0]
1525
+ out.parent.mkdir(parents=True, exist_ok=True)
1526
+ out.write_text(json.dumps(parsed.model_dump(mode="json"), indent=2) + "\n", encoding="utf-8")
1527
+ return parsed, []
1528
+
1529
+
1530
+ async def _handle_candidate_submit(
1531
+ ctx: AppContext,
1532
+ queue: TaskQueue,
1533
+ record: AgentTaskRecord,
1534
+ round_state: BestOfNRound,
1535
+ cand_num: int,
1536
+ checks: list[CheckResult],
1537
+ verdict: loop.Verdict,
1538
+ ) -> Selection | None:
1539
+ """Handle a best-of-N candidate submission: update state and run selection if all done.
1540
+
1541
+ A candidate's submit records its check results in the round state but does NOT:
1542
+ - Remove its workspace (needed for copy-back if it wins)
1543
+ - Accept/reject the base record (done after selection)
1544
+ - Settle the candidate record (done after selection)
1545
+
1546
+ When all candidates are submitted, this function:
1547
+ 1. Runs `select_best` (deterministic key; Critic tiebreak not wired yet)
1548
+ 2. If there's a winner: copies winner's outputs to user's tree, accepts BASE record
1549
+ 3. If no candidate passed: rejects BASE record as one counted try
1550
+ 4. Marks all candidate records terminal
1551
+ 5. Removes all candidate workspaces
1552
+ 6. Removes the round state file
1553
+
1554
+ Returns the Selection result if all candidates are done and selection was run,
1555
+ otherwise None.
1556
+ """
1557
+ # Check if this candidate was already submitted (double submit protection)
1558
+ existing_cand = next((c for c in round_state.candidates if c.candidate == cand_num), None)
1559
+ if existing_cand and existing_cand.status != "dispatched":
1560
+ raise AppError(
1561
+ f"candidate {cand_num} of {round_state.base_task_id!r} was already submitted "
1562
+ f"(status: {existing_cand.status})"
1563
+ )
1564
+
1565
+ # Update the candidate state in the round
1566
+ updated_candidates = []
1567
+ for cand in round_state.candidates:
1568
+ if cand.candidate == cand_num:
1569
+ status: Literal["dispatched", "submitted", "done", "failed", "cancelled"]
1570
+ if verdict.accepted:
1571
+ status = "done"
1572
+ elif verdict.status in ("budget_exhausted", "needs_human"):
1573
+ status = "failed"
1574
+ else:
1575
+ status = "failed"
1576
+
1577
+ updated_cand = CandidateState(
1578
+ candidate=cand.candidate,
1579
+ variant=cand.variant,
1580
+ task_id=cand.task_id,
1581
+ workspace=cand.workspace,
1582
+ status=status,
1583
+ checks=tuple(checks),
1584
+ passed=verdict.accepted,
1585
+ errors=error_count(checks),
1586
+ warnings=warning_count(checks),
1587
+ spend=Spend(tries=1), # Each submit counts as one try
1588
+ )
1589
+ updated_candidates.append(updated_cand)
1590
+ else:
1591
+ updated_candidates.append(cand)
1592
+
1593
+ updated_round = BestOfNRound(
1594
+ schema_version=round_state.schema_version,
1595
+ base_task_id=round_state.base_task_id,
1596
+ run_id=round_state.run_id,
1597
+ round_num=round_state.round_num,
1598
+ n=round_state.n,
1599
+ candidates=tuple(updated_candidates),
1600
+ ledger_tries=round_state.ledger_tries,
1601
+ ledger_used=round_state.ledger_used + 1,
1602
+ winner=round_state.winner,
1603
+ selection=round_state.selection,
1604
+ status=round_state.status,
1605
+ )
1606
+ _save_best_of_round(ctx, updated_round)
1607
+
1608
+ # Check if all candidates are done
1609
+ all_done = all(c.status in ("done", "failed", "cancelled") for c in updated_candidates)
1610
+ if not all_done:
1611
+ return None
1612
+
1613
+ # All candidates done - run selection
1614
+ updated_round = updated_round.model_copy(update={"status": "selecting"})
1615
+ _save_best_of_round(ctx, updated_round)
1616
+
1617
+ # Build Candidate objects for select_best
1618
+ # Use the round state's candidate info (workspace paths) since those are the actual
1619
+ # paths before any cleanup - the queue records may have stale info
1620
+ candidates: list[Candidate] = []
1621
+ for cand in updated_candidates:
1622
+ workspace_path = Path(cand.workspace) if cand.workspace else None
1623
+ candidates.append(
1624
+ Candidate(
1625
+ id=str(cand.candidate),
1626
+ passed=cand.passed,
1627
+ checks=cand.checks,
1628
+ finished=cand.status != "cancelled",
1629
+ workspace=workspace_path if workspace_path and workspace_path.exists() else None,
1630
+ )
1631
+ )
1632
+
1633
+ # Run selection (deterministic key only; Critic tiebreak is NOT wired in this
1634
+ # implementation - that requires the review rule infrastructure and will be wired
1635
+ # in a follow-up, say so in the report)
1636
+ selection = await select_best(candidates, metrics=None, tiebreak=None, ledger=None)
1637
+
1638
+ # Emit select event
1639
+ _emit(
1640
+ ctx,
1641
+ updated_round.run_id,
1642
+ updated_round.base_task_id,
1643
+ "tool_call",
1644
+ {
1645
+ "tool": "select",
1646
+ "event": "select",
1647
+ "best_of": updated_round.round_num,
1648
+ "status": (
1649
+ "done" if selection.winner is not None and not selection.none_passed else "failed"
1650
+ ),
1651
+ "winner": selection.winner,
1652
+ "decided_by": selection.decided_by,
1653
+ "none_passed": selection.none_passed,
1654
+ "order": list(selection.order),
1655
+ },
1656
+ )
1657
+
1658
+ # Get the base record to accept/reject
1659
+ base_record = queue.get(updated_round.base_task_id)
1660
+ if base_record is None:
1661
+ raise AppError(f"base task {updated_round.base_task_id!r} not found")
1662
+
1663
+ # Get the winning candidate's info from the round state (not from queue - workspace
1664
+ # path is authoritative in the round state)
1665
+ winner_cand_state: CandidateState | None = None
1666
+ winner_cand_record: AgentTaskRecord | None = None
1667
+ copy_result: CopyBackResult | None = None
1668
+
1669
+ if selection.winner is not None and not selection.none_passed:
1670
+ winner_num = int(selection.winner)
1671
+ winner_cand_state = next((c for c in updated_candidates if c.candidate == winner_num), None)
1672
+ if winner_cand_state:
1673
+ winner_cand_record = queue.get(winner_cand_state.task_id)
1674
+
1675
+ # If we have a winner and it passed, copy back its outputs to user's tree
1676
+ if winner_cand_state and winner_cand_record and winner_cand_state.workspace:
1677
+ workspace_path = Path(winner_cand_state.workspace)
1678
+ if workspace_path.exists():
1679
+ copy_result = copy_outputs_back(
1680
+ workspace_path,
1681
+ ctx.root,
1682
+ winner_cand_record.allowed_writes,
1683
+ winner_cand_record.seed_hashes,
1684
+ )
1685
+ if not copy_result.ok:
1686
+ # Copy-back failed - treat as no winner (infra issue, not counted as try)
1687
+ selection = Selection(
1688
+ winner=None,
1689
+ decided_by="none",
1690
+ none_passed=True,
1691
+ order=selection.order,
1692
+ keys=selection.keys,
1693
+ critic_note=f"copy-back failed: {copy_result.reason}",
1694
+ )
1695
+ winner_cand_state = None
1696
+ winner_cand_record = None
1697
+
1698
+ # Accept or reject the BASE record based on selection outcome
1699
+ graph = _graph(ctx)
1700
+ rule, instance = _instance(graph, updated_round.base_task_id)
1701
+
1702
+ if selection.winner is not None and not selection.none_passed and winner_cand_record:
1703
+ # Winner found and copy-back succeeded - accept the BASE record
1704
+ # Get output hashes from the user's tree (where outputs were just copied)
1705
+ base_result = AgentResult(
1706
+ status="done",
1707
+ files_written=winner_cand_record.allowed_writes,
1708
+ )
1709
+ # Re-dispatch the base so we can accept it (it's in ready/rejected state)
1710
+ if base_record.status in OPEN_STATUSES:
1711
+ base_record = queue.dispatch(
1712
+ updated_round.base_task_id,
1713
+ run_id=updated_round.run_id,
1714
+ baseline={}, # Empty baseline - we're not checking changes
1715
+ denied_reads=(),
1716
+ )
1717
+ if base_record.status == "dispatched":
1718
+ base_record = queue.mark_submitted(updated_round.base_task_id)
1719
+ base_record = queue.accept(
1720
+ updated_round.base_task_id,
1721
+ result=base_result,
1722
+ output_hashes=output_hashes(ctx.store, task_for(rule, instance)),
1723
+ )
1724
+ else:
1725
+ # No winner or none passed - reject the BASE record as one counted try
1726
+ # Use the best candidate's redo text if available
1727
+ best_cand = updated_candidates[0] if updated_candidates else None
1728
+ redo_text = (
1729
+ f"Best-of-{updated_round.n} round {updated_round.round_num}: no candidate passed. "
1730
+ )
1731
+ if best_cand:
1732
+ best_cand_record = queue.get(best_cand.task_id)
1733
+ if best_cand_record and best_cand_record.state.redo:
1734
+ redo_text += f"Best candidate's issues: {best_cand_record.state.redo}"
1735
+ else:
1736
+ # Get the first few issues from the best candidate's checks
1737
+ issues = [i.msg for c in best_cand.checks if not c.ok for i in c.issues[:2]][:3]
1738
+ if issues:
1739
+ redo_text += f"Issues: {'; '.join(issues)}"
1740
+
1741
+ base_result = AgentResult(
1742
+ status="failed",
1743
+ files_written=(),
1744
+ )
1745
+ # Re-dispatch the base so we can reject it (it's in ready state)
1746
+ if base_record.status in OPEN_STATUSES:
1747
+ base_record = queue.dispatch(
1748
+ updated_round.base_task_id,
1749
+ run_id=updated_round.run_id,
1750
+ baseline={},
1751
+ denied_reads=(),
1752
+ )
1753
+ if base_record.status == "dispatched":
1754
+ base_record = queue.mark_submitted(updated_round.base_task_id)
1755
+ # Reject counts as one try for the base
1756
+ base_record = queue.reject(
1757
+ updated_round.base_task_id,
1758
+ result=base_result,
1759
+ reasons=(redo_text,),
1760
+ counted=True,
1761
+ exhausted=base_record.attempts + 1 >= base_record.tries,
1762
+ budget_state=TaskBudgetState(
1763
+ dispatches=base_record.state.dispatches,
1764
+ tries_used=base_record.state.tries_used + 1,
1765
+ failed_tries=base_record.state.failed_tries + 1,
1766
+ redo=redo_text,
1767
+ label="verification",
1768
+ ),
1769
+ )
1770
+
1771
+ # Mark all candidate records as terminal (accepted for winner, rejected for losers)
1772
+ # so next_task never hands them out again
1773
+ for cand in updated_candidates:
1774
+ cand_record = queue.get(cand.task_id)
1775
+ if cand_record is None:
1776
+ continue
1777
+ is_winner = (
1778
+ selection.winner is not None
1779
+ and not selection.none_passed
1780
+ and cand.candidate == int(selection.winner)
1781
+ )
1782
+ if cand_record.status == "dispatched":
1783
+ cand_record = queue.mark_submitted(cand.task_id)
1784
+ if cand_record.status != "submitted":
1785
+ continue # Already terminal
1786
+ if is_winner:
1787
+ # The winner is accepted (its outputs are what landed)
1788
+ cand_result = AgentResult(status="done", files_written=cand_record.allowed_writes)
1789
+ queue.accept(
1790
+ cand.task_id,
1791
+ result=cand_result,
1792
+ output_hashes={}, # Candidate's outputs are in workspace, not user tree
1793
+ )
1794
+ else:
1795
+ # Losers are rejected (budget_exhausted so they're never re-dispatched)
1796
+ cand_result = AgentResult(status="failed", files_written=())
1797
+ queue.reject(
1798
+ cand.task_id,
1799
+ result=cand_result,
1800
+ reasons=("not selected as winner in best-of-N round",),
1801
+ counted=False, # Don't count against the candidate's own budget
1802
+ exhausted=True, # Mark as terminal
1803
+ )
1804
+
1805
+ # Clean up all candidate workspaces
1806
+ vcs = ctx.registry.get("vcs", "git")
1807
+ for cand in updated_candidates:
1808
+ if cand.workspace:
1809
+ with contextlib.suppress(Exception):
1810
+ remove_workspace(ctx, vcs, cand.workspace)
1811
+
1812
+ # Update round state with selection result and mark as done
1813
+ final_round = BestOfNRound(
1814
+ schema_version=updated_round.schema_version,
1815
+ base_task_id=updated_round.base_task_id,
1816
+ run_id=updated_round.run_id,
1817
+ round_num=updated_round.round_num,
1818
+ n=updated_round.n,
1819
+ candidates=tuple(updated_candidates),
1820
+ ledger_tries=updated_round.ledger_tries,
1821
+ ledger_used=updated_round.ledger_used,
1822
+ winner=int(selection.winner) if selection.winner and not selection.none_passed else None,
1823
+ selection=selection,
1824
+ status="done" if selection.winner and not selection.none_passed else "failed",
1825
+ )
1826
+ _save_best_of_round(ctx, final_round)
1827
+
1828
+ # If the round failed (no candidate passed), remove the round state so next_task
1829
+ # can start a fresh round
1830
+ if selection.none_passed or selection.winner is None:
1831
+ _remove_best_of_round(ctx, updated_round.base_task_id)
1832
+
1833
+ return selection
1834
+
1835
+
1836
+ async def submit(
1837
+ ctx: AppContext, task_id: str, report: SubmitReport | None = None
1838
+ ) -> dict[str, Any]:
1839
+ """Check a dispatched task's work at once and accept or reject it (DESIGN.md 5.2).
1840
+
1841
+ The checks of the attempt, each run once: no file outside the task's outputs
1842
+ changed since dispatch, every output exists and is not empty, every input exists
1843
+ (`attempt_checks`), and, when those hold, the rule's checks. A failure gets its
1844
+ `label` (`loop.judge`, the M2-02a pieces) and counts against the budget:
1845
+
1846
+ - `rejected`: tries are left; the answer has the failed checks, the redo text,
1847
+ `next_tier`/`next_model` (the tier escalates after the first failed try) and
1848
+ `tries_left`; `next_task` hands the task out again with the redo text in its
1849
+ context (`previous_rejection`);
1850
+ - `infra` (a check errored, a tool could not run): no try is used, but after
1851
+ `max_infra_retries` such submits the task stops;
1852
+ - `budget_exhausted`: the tries ran out, two tries in a row left the same outputs
1853
+ and failures (`stagnation`), or the infra retries ran out. The `rule_fail` event
1854
+ and the run's HANDOFF.md are written at once, and the task is never handed out
1855
+ again until an input changes or it is rewound;
1856
+ - `needs_human`: the checks point at the spec or plan (`planning`).
1857
+
1858
+ A report with `status: needs_human` hands the task to a person without using a try.
1859
+ Tokens and cost are not known per task in this runtime: the loop's token and cost
1860
+ caps do not apply.
1861
+
1862
+ A review task (its role writes no files and has `engine_writes`) is submitted with
1863
+ `report.review`: rejected when it is missing, not JSON, not a `ReviewReport`, or
1864
+ invalid for the diff its context showed (`review_problems`); otherwise the engine
1865
+ writes it as the task's one output and then runs the usual checks. A valid review
1866
+ is accepted whatever its verdict: AI findings never block the build (DESIGN.md 4.8).
1867
+
1868
+ For best-of-N candidates: each candidate is submitted individually; after all are
1869
+ submitted, `select_best` is run (with Critic tiebreak for exact ties), and only the
1870
+ winner's outputs are copied back to the user's tree.
1871
+ """
1872
+ ctx.require_profile()
1873
+ _require_runtime(ctx)
1874
+ report = report or SubmitReport()
1875
+ queue = _queue(ctx)
1876
+ record = _record(queue, task_id)
1877
+ if record.status != "dispatched":
1878
+ raise AppError(f"task {task_id!r} is {record.status}, not dispatched; call next_task")
1879
+ graph = _graph(ctx)
1880
+
1881
+ # Handle candidate task_id lookup - need to find the real instance
1882
+ if is_candidate_id(task_id):
1883
+ base_task_id, _cand_num, _round_num = parse_candidate_id(task_id)
1884
+ rule, instance = _instance(graph, base_task_id)
1885
+ else:
1886
+ rule, instance = _instance(graph, task_id)
1887
+ reviewing = review_mod.is_review_role(find_role(record.role))
1888
+ if report.review is not None and not reviewing:
1889
+ raise AppError(
1890
+ f"task {task_id!r} is not a review task (role {record.role!r}): submit it "
1891
+ "without `review`"
1892
+ )
1893
+
1894
+ if report.status == "needs_human":
1895
+ if not report.open_questions:
1896
+ raise AppError("a submit with status 'needs_human' must list open_questions")
1897
+ result = AgentResult(
1898
+ status="needs_human",
1899
+ assumptions=report.assumptions,
1900
+ open_questions=report.open_questions,
1901
+ )
1902
+ record = queue.needs_human(task_id, result=result)
1903
+ restored = _settle(ctx, queue, record)
1904
+ _emit(
1905
+ ctx,
1906
+ record.run_id,
1907
+ task_id,
1908
+ "agent_turn",
1909
+ {
1910
+ "phase": "submit",
1911
+ "status": "needs_human",
1912
+ "try": record.state.tries_used + 1,
1913
+ "counted": False,
1914
+ "tier": record.current_tier,
1915
+ "label": "planning",
1916
+ "failed_checks": [],
1917
+ "open_questions": list(report.open_questions),
1918
+ "restored": restored,
1919
+ },
1920
+ )
1921
+ return _submit_answer(
1922
+ ctx, record, verdict=None, reasons=[], outside=[], missing=[], checks=[]
1923
+ )
1924
+
1925
+ record = queue.mark_submitted(task_id)
1926
+ try:
1927
+ review: ReviewReport | None = None
1928
+ review_reasons: list[str] = []
1929
+ if reviewing:
1930
+ review, review_reasons = await _take_review(ctx, graph, rule, instance, record, report)
1931
+ allowed = set(record.allowed_writes)
1932
+
1933
+ # For isolated tasks, diff the workspace; otherwise diff the user's tree
1934
+ workspace = record.workspace
1935
+ copy_back_result: CopyBackResult | None = None
1936
+ if workspace:
1937
+ workspace_path = Path(workspace)
1938
+ changed_in_allowed, changed_outside = diff_workspace(
1939
+ workspace_path, record.allowed_writes, record.seed_hashes
1940
+ )
1941
+ changed = [*changed_in_allowed, *changed_outside]
1942
+ outside = list(changed_outside)
1943
+ missing = (
1944
+ []
1945
+ if review_reasons
1946
+ else [p for p in record.allowed_writes if not (workspace_path / p).is_file()]
1947
+ )
1948
+ else:
1949
+ now = await snapshot(ctx, extra=record.allowed_writes)
1950
+ changed = _changed(queue.baseline(task_id), now)
1951
+ # Outputs of the other agent tasks: their own subagents write them (the guard
1952
+ # allows nobody else), whatever their status now: tasks that ran in parallel
1953
+ # are submitted one by one, and one may be accepted or rejected before this one.
1954
+ others = {
1955
+ w for other in queue.all() if other.task_id != task_id for w in other.allowed_writes
1956
+ }
1957
+ engine = _engine_outputs(graph)
1958
+ outside = [
1959
+ p for p in changed if p not in allowed and p not in others and p not in engine
1960
+ ]
1961
+ missing = (
1962
+ []
1963
+ if review_reasons
1964
+ else [p for p in record.allowed_writes if not (ctx.root / p).is_file()]
1965
+ )
1966
+ attempt = AgentResult(
1967
+ status="done",
1968
+ files_written=tuple(p for p in changed if p in allowed or p in outside),
1969
+ assumptions=report.assumptions,
1970
+ open_questions=report.open_questions,
1971
+ )
1972
+ # an isolated task is judged on its workspace: its outputs are there, not in the user's tree
1973
+ judged_store = (
1974
+ ArtifactStore(Path(workspace), ctx.store.labels, ctx.store.repo)
1975
+ if workspace
1976
+ else ctx.store
1977
+ )
1978
+ results = list(
1979
+ attempt_checks(instance, attempt, judged_store, allowed_writes=record.allowed_writes)
1980
+ )
1981
+ if reviewing:
1982
+ if review_reasons: # nothing was written: the reply is what failed
1983
+ results = [r for r in results if r.check_id != OUTPUTS_CHECK]
1984
+ results.append(loop.review_result(review_reasons))
1985
+
1986
+ checks: list[CheckResult] = []
1987
+ if not outside and not missing and not review_reasons:
1988
+ # For isolated tasks, run checks in the workspace; otherwise in user's tree
1989
+ check_root = Path(workspace) if workspace else None
1990
+ runner = ProfileCheckRunner(ctx, root_override=check_root)
1991
+ for check_id in rule.checks:
1992
+ checks.append(await runner.run(check_id, instance))
1993
+ results.extend(checks)
1994
+
1995
+ shown = _feedback_filter(ctx, record)
1996
+ verdict = await loop.judge(
1997
+ rule,
1998
+ record,
1999
+ results,
2000
+ attempt,
2001
+ judged_store.current_hashes(instance.outputs),
2002
+ shown=shown,
2003
+ )
2004
+ # The journal keeps every check's full result; the answer and the redo text
2005
+ # show only what the role may see.
2006
+ visible = list(shown.apply(checks)[0]) if shown is not None else checks
2007
+ reasons: list[str] = list(review_reasons)
2008
+ if outside:
2009
+ reasons.append(f"changed files outside the task's outputs: {', '.join(outside)}")
2010
+ if missing:
2011
+ reasons.append(f"missing outputs: {', '.join(missing)}")
2012
+ reasons.extend(_check_reason(c) for c in visible if not c.ok)
2013
+ if not reasons and not verdict.accepted:
2014
+ reasons = list(verdict.state.failures)
2015
+ state = verdict.state
2016
+ if verdict.stop is not None and record.run_id is not None:
2017
+ state = state.model_copy(update={"handoff": str(_handoff_path(ctx, record.run_id))})
2018
+ result = AgentResult(
2019
+ status="done" if verdict.accepted else "failed",
2020
+ files_written=tuple(p for p in changed if p in allowed),
2021
+ assumptions=report.assumptions,
2022
+ open_questions=report.open_questions,
2023
+ )
2024
+
2025
+ # Check if this is a best-of-N candidate
2026
+ is_best_of_candidate = is_candidate_id(task_id)
2027
+ best_of_round: BestOfNRound | None = None
2028
+ if is_best_of_candidate:
2029
+ base_task_id, cand_num, _round_num = parse_candidate_id(task_id)
2030
+ best_of_round = _load_best_of_round(ctx, base_task_id)
2031
+ # Validate: the round must exist and be running
2032
+ if best_of_round is None:
2033
+ raise AppError(
2034
+ f"cannot submit candidate {task_id!r}: no active best-of-N round "
2035
+ f"for base task {base_task_id!r}"
2036
+ )
2037
+ if best_of_round.status not in ("running", "selecting"):
2038
+ raise AppError(
2039
+ f"cannot submit candidate {task_id!r}: best-of-N round is already "
2040
+ f"{best_of_round.status}"
2041
+ )
2042
+
2043
+ # For isolated tasks: on acceptance, copy outputs back to user's tree
2044
+ # For best-of-N candidates: DON'T copy back yet - wait for selection
2045
+ # Refuse if user's tree changed since the workspace was seeded
2046
+ if verdict.accepted and workspace and not is_best_of_candidate:
2047
+ workspace_path = Path(workspace)
2048
+ copy_back_result = copy_outputs_back(
2049
+ workspace_path, ctx.root, record.allowed_writes, record.seed_hashes
2050
+ )
2051
+ if not copy_back_result.ok:
2052
+ # User's tree changed - reject and re-dispatch (not counted as a try)
2053
+ reasons.append(f"copy-back refused: {copy_back_result.reason}")
2054
+ # Create a rejection verdict without counting the try (infra issue)
2055
+ verdict = loop.Verdict(
2056
+ status="rejected",
2057
+ label="infra", # Concurrent edit is an infra issue
2058
+ counted=False,
2059
+ stop=None,
2060
+ tier=verdict.tier,
2061
+ state=verdict.state,
2062
+ tries=verdict.tries,
2063
+ failed=verdict.failed,
2064
+ questions=(),
2065
+ )
2066
+
2067
+ # For best-of-N candidates: DON'T accept/reject the candidate record here.
2068
+ # The candidate stays in "submitted" status; _handle_candidate_submit will
2069
+ # mark all candidates terminal after selection and accept/reject the BASE record.
2070
+ # Also DON'T remove the workspace - it's needed for copy-back if this candidate wins.
2071
+ if not is_best_of_candidate:
2072
+ if verdict.accepted:
2073
+ record = queue.accept(
2074
+ task_id,
2075
+ result=result,
2076
+ output_hashes=output_hashes(ctx.store, task_for(rule, instance)),
2077
+ budget_state=state,
2078
+ )
2079
+ elif verdict.status == "needs_human":
2080
+ questions = tuple(dict.fromkeys((*report.open_questions, *verdict.questions)))
2081
+ record = queue.needs_human(
2082
+ task_id,
2083
+ result=result.model_copy(
2084
+ update={"status": "needs_human", "open_questions": questions}
2085
+ ),
2086
+ budget_state=state,
2087
+ )
2088
+ else:
2089
+ record = queue.reject(
2090
+ task_id,
2091
+ result=result,
2092
+ reasons=tuple(reasons),
2093
+ outside=tuple(outside),
2094
+ counted=verdict.counted,
2095
+ exhausted=verdict.status == "budget_exhausted",
2096
+ budget_state=state,
2097
+ )
2098
+ except BaseException:
2099
+ with contextlib.suppress(QueueError):
2100
+ queue.unsubmit(task_id)
2101
+ raise
2102
+
2103
+ # Best-of-N selection: update round state and run selection when all candidates done
2104
+ # For best-of-N candidates, _handle_candidate_submit handles everything:
2105
+ # - Recording the candidate's result in round state
2106
+ # - Running selection when all candidates are done
2107
+ # - Copying the winner's outputs to user's tree
2108
+ # - Accepting/rejecting the BASE record
2109
+ # - Marking all candidate records terminal
2110
+ # - Cleaning up all workspaces
2111
+ selection_result: Selection | None = None
2112
+ restored = []
2113
+ if is_best_of_candidate and best_of_round is not None:
2114
+ base_task_id, cand_num, _round_num = parse_candidate_id(task_id)
2115
+ selection_result = await _handle_candidate_submit(
2116
+ ctx, queue, record, best_of_round, cand_num, checks, verdict
2117
+ )
2118
+ # After _handle_candidate_submit, the candidate record is now terminal
2119
+ record = queue.require(task_id)
2120
+ else:
2121
+ # Non-candidate tasks: settle (move-aside restore + workspace cleanup)
2122
+ restored = _settle(ctx, queue, record)
2123
+ if record.status == "accepted": # the person's note is answered; the gate decides again
2124
+ _revision_path(ctx, task_id).unlink(missing_ok=True)
2125
+
2126
+ for check in checks:
2127
+ _emit(ctx, record.run_id, task_id, "check_result", check.model_dump(mode="json"))
2128
+ payload: dict[str, Any] = {
2129
+ "phase": "submit",
2130
+ "status": record.status,
2131
+ "attempt": record.dispatches,
2132
+ "try": verdict.state.tries_used if verdict.counted else verdict.state.tries_used + 1,
2133
+ "counted": verdict.counted,
2134
+ "tier": verdict.tier,
2135
+ "label": verdict.label,
2136
+ "failed_checks": [r.check_id for r in verdict.failed],
2137
+ "reasons": reasons,
2138
+ "files_written": list(result.files_written),
2139
+ "assumptions": list(report.assumptions),
2140
+ "open_questions": list(report.open_questions),
2141
+ "restored": restored,
2142
+ }
2143
+ if review is not None:
2144
+ payload["review"] = {"verdict": review.verdict, "comments": len(review.comments)}
2145
+ # Add best-of-N candidate info to payload
2146
+ if is_best_of_candidate:
2147
+ base_task_id, cand_num, _round_num = parse_candidate_id(task_id)
2148
+ payload["best_of"] = {
2149
+ "base_task": base_task_id,
2150
+ "candidate": cand_num,
2151
+ }
2152
+ if selection_result is not None:
2153
+ payload["best_of"]["selection"] = {
2154
+ "winner": selection_result.winner,
2155
+ "decided_by": selection_result.decided_by,
2156
+ }
2157
+ _emit(ctx, record.run_id, task_id, "agent_turn", payload)
2158
+ if verdict.stop is not None:
2159
+ _stop(ctx, record)
2160
+
2161
+ answer = _submit_answer(
2162
+ ctx,
2163
+ record,
2164
+ verdict=verdict,
2165
+ reasons=reasons,
2166
+ outside=outside,
2167
+ missing=missing,
2168
+ checks=visible,
2169
+ )
2170
+ if is_best_of_candidate:
2171
+ # A candidate is not a task: it is not accepted or stopped on its own. Say what the
2172
+ # submit meant for the round, not what its private record says.
2173
+ answer["stop_reason"] = None
2174
+ answer["handoff"] = None
2175
+ if selection_result is None:
2176
+ answer["status"], answer["accepted"] = "submitted", False
2177
+ answer["next"] = (
2178
+ "this candidate is in; wait for the other candidate(s) of the round to be "
2179
+ "submitted, then call next_task"
2180
+ )
2181
+ elif selection_result.winner is not None:
2182
+ won = str(cand_num) == str(selection_result.winner)
2183
+ answer["status"], answer["accepted"] = ("won" if won else "lost"), won
2184
+ answer["next"] = "the round is decided and its winner landed; call next_task"
2185
+ else:
2186
+ answer["status"], answer["accepted"] = "round_failed", False
2187
+ answer["next"] = (
2188
+ "no candidate of the round passed, nothing landed; call next_task: a new round "
2189
+ "starts, or the task stops when its budget is spent"
2190
+ )
2191
+ # Add selection result to answer if this was the final candidate
2192
+ if selection_result is not None:
2193
+ answer["selection"] = {
2194
+ "winner": selection_result.winner,
2195
+ "decided_by": selection_result.decided_by,
2196
+ "none_passed": selection_result.none_passed,
2197
+ "order": list(selection_result.order),
2198
+ }
2199
+ return answer
2200
+
2201
+
2202
+ def _stop(ctx: AppContext, record: AgentTaskRecord) -> None:
2203
+ """Journal a stopped task's `rule_fail` in its run, and write that run's HANDOFF.md."""
2204
+ if record.run_id is None:
2205
+ return
2206
+ info = loop.stop_info(record)
2207
+ payload: dict[str, Any] = {"message": loop.stop_message(info), "agent": info}
2208
+ if record.last_result is not None:
2209
+ payload["result"] = record.last_result.model_dump(mode="json")
2210
+ if record.last_result.open_questions:
2211
+ payload["open_questions"] = list(record.last_result.open_questions)
2212
+ _emit(
2213
+ ctx,
2214
+ record.run_id,
2215
+ record.task_id,
2216
+ "rule_fail",
2217
+ payload,
2218
+ failure_label=record.state.label,
2219
+ )
2220
+ _write_run_handoff(ctx, record.run_id)
2221
+
2222
+
2223
+ def _submit_answer(
2224
+ ctx: AppContext,
2225
+ record: AgentTaskRecord,
2226
+ *,
2227
+ verdict: loop.Verdict | None,
2228
+ reasons: list[str],
2229
+ outside: list[str],
2230
+ missing: list[str],
2231
+ checks: list[CheckResult],
2232
+ ) -> dict[str, Any]:
2233
+ state = record.state
2234
+ if record.status == "rejected":
2235
+ hint = (
2236
+ "call next_task: it hands the task out again (model next_model); start its "
2237
+ "subagent with the task's prompt only, the redo text is in its context"
2238
+ )
2239
+ elif record.status == "budget_exhausted":
2240
+ hint = (
2241
+ "stop working on this task: it used its budget; a person must look at it (see handoff)"
2242
+ )
2243
+ elif record.status == "needs_human":
2244
+ hint = "a person must answer the open questions; then build again"
2245
+ else:
2246
+ hint = "call next_task for the next tasks"
2247
+ rejected = record.status == "rejected"
2248
+ # An agent's own needs_human report (no verdict): M2-02a labels it `planning`.
2249
+ label: FailureLabel | None = verdict.label if verdict is not None else "planning"
2250
+ return {
2251
+ "task_id": record.task_id,
2252
+ "accepted": record.status == "accepted",
2253
+ "status": record.status,
2254
+ "attempts": record.attempts,
2255
+ "tries": record.tries,
2256
+ "label": label,
2257
+ "counted": verdict.counted if verdict is not None else False,
2258
+ "failed_checks": loop.failed_checks(verdict.failed) if verdict is not None else [],
2259
+ "redo": state.redo if verdict is not None and not verdict.accepted else None,
2260
+ "next_tier": record.current_tier if rejected else None,
2261
+ "next_model": _model_for(ctx, record) if rejected else None,
2262
+ "tries_left": max(record.tries - state.tries_used, 0),
2263
+ "budget": {
2264
+ "used": state.tries_used,
2265
+ "allowed": record.tries,
2266
+ "infra_retries": state.infra_failures,
2267
+ "max_infra_retries": loop.MAX_INFRA_RETRIES,
2268
+ "dispatches": state.dispatches,
2269
+ "max_dispatches": loop.dispatch_limit(record),
2270
+ },
2271
+ "stop_reason": state.stop if verdict is not None else None,
2272
+ "handoff": state.handoff if verdict is not None and verdict.stop is not None else None,
2273
+ "reasons": reasons,
2274
+ "outside_outputs": outside,
2275
+ "missing": missing,
2276
+ "checks": [
2277
+ {"check_id": c.check_id, "status": c.status, "issues": [i.msg for i in c.issues[:10]]}
2278
+ for c in checks
2279
+ ],
2280
+ "result": record.last_result.model_dump(mode="json") if record.last_result else None,
2281
+ "next": hint,
2282
+ }
2283
+
2284
+
2285
+ __all__ = ["SubmitReport", "get_context", "next_task", "snapshot", "submit"]