codebase-explorer 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbe/__init__.py +9 -0
- cbe/__main__.py +4 -0
- cbe/aggregation.py +183 -0
- cbe/api_completion.py +410 -0
- cbe/batching.py +283 -0
- cbe/catalog_query.py +376 -0
- cbe/checks.py +390 -0
- cbe/cli.py +434 -0
- cbe/concrete_facts.py +321 -0
- cbe/data/LICENSE.tiktoken +30 -0
- cbe/data/o200k_base.tiktoken +199998 -0
- cbe/declarations.py +537 -0
- cbe/docs.py +929 -0
- cbe/generate.py +316 -0
- cbe/grammars.py +741 -0
- cbe/graph.py +857 -0
- cbe/graph_backends/__init__.py +43 -0
- cbe/graph_backends/_calls.py +262 -0
- cbe/graph_backends/_paths.py +42 -0
- cbe/graph_backends/identity.py +93 -0
- cbe/graph_backends/imports.py +256 -0
- cbe/graph_backends/joern.py +1021 -0
- cbe/graph_backends/resolution.py +288 -0
- cbe/graph_backends/toolchain.py +396 -0
- cbe/grounding.py +57 -0
- cbe/host_run.py +1752 -0
- cbe/model_settings.py +80 -0
- cbe/projection.py +305 -0
- cbe/prompts.py +316 -0
- cbe/records.py +200 -0
- cbe/skeleton.py +474 -0
- cbe/source_context.py +709 -0
- cbe/symbols.py +136 -0
- cbe/token_budget.py +68 -0
- cbe/treename.py +398 -0
- cbe/usage.py +300 -0
- cbe_install/__init__.py +11 -0
- cbe_install/cli.py +434 -0
- cbe_install/resources/agents/cbe-writer.md +16 -0
- cbe_install/resources/codebase-explorer/SKILL.md +101 -0
- cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
- cbe_install/resources/codebase-explorer/references/codex.md +37 -0
- codebase_explorer-2.2.0.dist-info/METADATA +233 -0
- codebase_explorer-2.2.0.dist-info/RECORD +47 -0
- codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
- codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
- codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/host_run.py
ADDED
|
@@ -0,0 +1,1752 @@
|
|
|
1
|
+
"""Durable run state shared by both generation drivers: plan, schedule, check, render, resume.
|
|
2
|
+
|
|
3
|
+
``cbe host`` lets a host agent (Claude Code, Codex, ...) dispatch one subagent
|
|
4
|
+
per task; ``cbe generate`` runs the same tasks as tool-free OpenAI-compatible
|
|
5
|
+
API completions. Either way this state machine keeps every deterministic
|
|
6
|
+
step: freezing the source context, reading batches (``batching``), the unit
|
|
7
|
+
inventory (``symbols``), task order and leases, result checks (``checks``),
|
|
8
|
+
rendering and structural verification (``docs``). Task bodies come from
|
|
9
|
+
``prompts``; usage and cost reports from ``usage``.
|
|
10
|
+
|
|
11
|
+
The runtime records are the resume point. ``<run>/host/state.json`` holds the
|
|
12
|
+
frozen plan and every task's state, attempts, timings, and output path;
|
|
13
|
+
``<run>/progress.log`` gets one human-readable line per event; and
|
|
14
|
+
``<run>/STATUS.md`` is a snapshot table rewritten on every change. Every command
|
|
15
|
+
re-reads the state under a lock, so any host continues with ``resume``.
|
|
16
|
+
|
|
17
|
+
No single task failure voids a run. Detail tasks have three answer attempts;
|
|
18
|
+
other tasks have two. A detail answer is decoded unit by unit: what is usable is
|
|
19
|
+
kept, and a re-ask lists only the gaps (symbols with logic that no unit explains,
|
|
20
|
+
units that were rejected). After the last attempt the remaining gaps are indexed
|
|
21
|
+
without prose and flagged ``unexplained``; the task still completes.
|
|
22
|
+
|
|
23
|
+
The module tree is made by the program: once all detail is settled ``skeleton.build``
|
|
24
|
+
freezes its structure into the run state, and ``tree_name_<k>`` tasks (batches of leaf
|
|
25
|
+
modules) and one ``tree_top`` task (all internal nodes) only name it (``treename``). Those
|
|
26
|
+
answers are decoded node by node like detail answers; a node that stays unnamed after the
|
|
27
|
+
last attempt gets a program-made title and the task still completes.
|
|
28
|
+
|
|
29
|
+
Other tasks are re-asked with the exact error, and a usable final answer is normalized and
|
|
30
|
+
flagged. Only an unusable final answer marks a task failed. Finished batches always
|
|
31
|
+
render; failed parts stay pending until retried, and the overview task can be
|
|
32
|
+
retried on its own.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import contextlib
|
|
38
|
+
import gzip
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import secrets
|
|
44
|
+
import time
|
|
45
|
+
from collections import Counter
|
|
46
|
+
from datetime import datetime, timezone
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any, Iterator
|
|
49
|
+
|
|
50
|
+
from cbe import aggregation, batching, catalog_query, checks, docs, grounding, projection, prompts, skeleton, symbols, treename
|
|
51
|
+
from cbe import __version__
|
|
52
|
+
from cbe import source_context
|
|
53
|
+
from cbe.grammars import ContextError
|
|
54
|
+
from cbe.graph import check_graph_source, read_graph_quality
|
|
55
|
+
from cbe.token_budget import count_text_tokens
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
SCHEMA = "cbe-host-run/3"
|
|
59
|
+
RUN_VERSION_ERROR = catalog_query.RUN_VERSION_ERROR
|
|
60
|
+
MAX_ATTEMPTS = 2
|
|
61
|
+
DETAIL_MAX_ATTEMPTS = 3
|
|
62
|
+
REVIEW_MODES = ("none", "sample", "all")
|
|
63
|
+
# One switch for both drivers: change to "all" to review every module by default.
|
|
64
|
+
DEFAULT_REVIEW = "sample"
|
|
65
|
+
TREE_KINDS = ("tree_name", "tree_top")
|
|
66
|
+
FALLBACK_CODES = ("source_reassembly_fallback", "context_fallback") # diagnostics kept in the run state
|
|
67
|
+
ORDER = {"module": 1, "repair": 2, "tree_name": 3, "tree_top": 3, "review": 4, "system": 5}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HostRunError(ValueError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _now() -> str:
|
|
75
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def epoch(stamp: str) -> float:
|
|
79
|
+
return datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def state_dir(run_dir: Path) -> Path:
|
|
83
|
+
return run_dir / "host"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_json(path: Path, value: Any) -> None:
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
89
|
+
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
90
|
+
os.replace(temporary, path)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _RunState(dict[str, Any]):
|
|
94
|
+
# Cache only within one locked operation; attributes are never serialized.
|
|
95
|
+
context: dict[str, Any] | None = None
|
|
96
|
+
aggregation_cache: tuple | None = None
|
|
97
|
+
tree_cache: tuple | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def context_digest(context: dict[str, Any]) -> str:
|
|
101
|
+
"""Canonical digest of a frozen context object.
|
|
102
|
+
|
|
103
|
+
Sorted keys and fixed separators make the digest reproducible across
|
|
104
|
+
processes and platforms, so a rewritten ``context.json`` cannot pass as
|
|
105
|
+
the frozen snapshot."""
|
|
106
|
+
payload = json.dumps(context, ensure_ascii=False, sort_keys=True,
|
|
107
|
+
separators=(",", ":")).encode("utf-8")
|
|
108
|
+
return hashlib.sha256(payload).hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _verify_graph_manifest(repo: Path, state: dict[str, Any], context: dict[str, Any]) -> None:
|
|
112
|
+
"""Re-check that the frozen graph still matches the planned source (see ``check_graph_source``)."""
|
|
113
|
+
graph = context.get("graph")
|
|
114
|
+
if not graph:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
check_graph_source(repo, graph, {row["path"]: row["digest"] for row in state["files"]})
|
|
118
|
+
except ContextError as exc:
|
|
119
|
+
raise HostRunError(f"frozen graph does not match the planned source: {exc.message}; "
|
|
120
|
+
"start a new plan") from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _verified_context(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
|
|
124
|
+
"""Return the frozen context only if it is byte-identical to the plan.
|
|
125
|
+
|
|
126
|
+
Runs before every prompt, dispatch, submission, resume and verification, so
|
|
127
|
+
a rewritten ``context.json`` cannot smuggle a forged graph, chunk or
|
|
128
|
+
manifest into a model prompt or into evidence."""
|
|
129
|
+
path = run_dir / "context.json"
|
|
130
|
+
if not path.is_file():
|
|
131
|
+
raise HostRunError(f"no frozen source context at {path}")
|
|
132
|
+
raw = path.read_bytes()
|
|
133
|
+
raw_digest = hashlib.sha256(raw).hexdigest()
|
|
134
|
+
frozen = state.get("context") or {}
|
|
135
|
+
if raw_digest != frozen.get("raw_digest"):
|
|
136
|
+
if frozen.get("raw_digest") is None:
|
|
137
|
+
raise HostRunError(RUN_VERSION_ERROR)
|
|
138
|
+
raise HostRunError("frozen source context was modified after planning; start a new plan")
|
|
139
|
+
context = state.context if isinstance(state, _RunState) else None
|
|
140
|
+
if context is None:
|
|
141
|
+
context = json.loads(raw)
|
|
142
|
+
if context.get("schema") != frozen.get("schema"):
|
|
143
|
+
raise HostRunError("frozen context schema changed after planning; start a new plan")
|
|
144
|
+
if bool(context.get("graph")) != bool(frozen.get("graph_present")):
|
|
145
|
+
raise HostRunError("frozen context graph presence changed after planning; start a new plan")
|
|
146
|
+
planned = {row["path"]: row["digest"] for row in state["files"]}
|
|
147
|
+
context_files = {row.get("path"): row.get("sha256") for row in context.get("files") or []}
|
|
148
|
+
for path, digest in planned.items():
|
|
149
|
+
if context_files.get(path) != digest:
|
|
150
|
+
raise HostRunError(f"frozen context and plan disagree on {path}; start a new plan")
|
|
151
|
+
_verify_graph_manifest(Path(state["repo"]), state, context)
|
|
152
|
+
if isinstance(state, _RunState):
|
|
153
|
+
state.context = context
|
|
154
|
+
return context
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _build_context(repo: Path, paths: list[str],
|
|
158
|
+
graph_path: Path | None = None) -> dict[str, Any]:
|
|
159
|
+
"""Freeze the source context once through the packaged product module.
|
|
160
|
+
|
|
161
|
+
``cbe.source_context`` is a required product module; there is no second
|
|
162
|
+
context implementation. A graph path is validated against the frozen
|
|
163
|
+
source by ``build_context``.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
return source_context.build_context(repo, paths, graph_path=graph_path)
|
|
167
|
+
except Exception as exc: # noqa: BLE001 - surface a locatable context/graph failure
|
|
168
|
+
raise HostRunError(f"source context build failed: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _semantic_units(run_dir: Path, state: dict[str, Any]) -> tuple[dict[str, dict], dict[str, dict]]:
|
|
172
|
+
"""Merge accepted module/repair detail payloads into (units, dispositions).
|
|
173
|
+
|
|
174
|
+
A repair output supersedes its module's original output, matching
|
|
175
|
+
``_module_output``; unit ids are keyed once and a cross-batch duplicate is
|
|
176
|
+
caught by the deterministic checks rather than silently overwritten."""
|
|
177
|
+
units: dict[str, dict] = {}
|
|
178
|
+
dispositions: dict[str, dict] = {}
|
|
179
|
+
for row in state["modules"]:
|
|
180
|
+
output = _module_output(run_dir, state, row["number"])
|
|
181
|
+
if not output:
|
|
182
|
+
continue
|
|
183
|
+
for unit in output.get("units") or []:
|
|
184
|
+
uid = unit.get("id") if isinstance(unit, dict) else None
|
|
185
|
+
if uid and uid not in units:
|
|
186
|
+
units[uid] = unit
|
|
187
|
+
for key, entry in (output.get("dispositions") or {}).items():
|
|
188
|
+
dispositions.setdefault(key, entry)
|
|
189
|
+
return units, dispositions
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _tree_names(run_dir: Path, state: dict[str, Any]) -> dict[str, dict]:
|
|
193
|
+
"""Records of every node a finished tree task has named, by node id."""
|
|
194
|
+
names: dict[str, dict] = {}
|
|
195
|
+
for tid, task in state["tasks"].items():
|
|
196
|
+
if task["kind"] in TREE_KINDS and task["status"] == "done":
|
|
197
|
+
names.update((_accepted(run_dir, tid) or {}).get("names") or {})
|
|
198
|
+
return names
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _semantic_tree(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
|
|
202
|
+
"""The tree as far as it is named: the frozen skeleton with the accepted names, program-made
|
|
203
|
+
titles for the rest. Before the skeleton is frozen it is built from the units finished so far."""
|
|
204
|
+
records, edges = _aggregation_inputs(run_dir, state)
|
|
205
|
+
fingerprint = _detail_fingerprint(run_dir, state)
|
|
206
|
+
frozen = state.get("skeleton")
|
|
207
|
+
if frozen and frozen["input_digest"] == fingerprint:
|
|
208
|
+
nodes = frozen["nodes"]
|
|
209
|
+
else:
|
|
210
|
+
cached = getattr(state, "tree_cache", None)
|
|
211
|
+
if cached is None or cached[0] != fingerprint:
|
|
212
|
+
cached = fingerprint, skeleton.build(records, edges)["nodes"]
|
|
213
|
+
if isinstance(state, _RunState):
|
|
214
|
+
state.tree_cache = cached
|
|
215
|
+
nodes = cached[1]
|
|
216
|
+
if not nodes:
|
|
217
|
+
return treename.empty_tree()
|
|
218
|
+
return treename.assemble(treename.Tree(nodes), _tree_names(run_dir, state), records, edges,
|
|
219
|
+
Path(state["repo"]).name)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _detail_fingerprint(run_dir: Path, state: dict[str, Any]) -> str:
|
|
223
|
+
digest = hashlib.sha256()
|
|
224
|
+
for row in state["modules"]:
|
|
225
|
+
tid = f"repair_{row['number'] + 1}"
|
|
226
|
+
if state["tasks"].get(tid, {}).get("status") != "done":
|
|
227
|
+
tid = f"module_{row['number'] + 1}"
|
|
228
|
+
digest.update(f"{tid}:{state['tasks'][tid]['status']}".encode())
|
|
229
|
+
path = state_dir(run_dir) / "accepted" / f"{tid}.json"
|
|
230
|
+
if state["tasks"][tid]["status"] == "done" and path.is_file():
|
|
231
|
+
digest.update(path.read_bytes())
|
|
232
|
+
return digest.hexdigest()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _aggregation_inputs(run_dir: Path, state: dict[str, Any]) -> tuple[dict, list[dict]]:
|
|
236
|
+
fingerprint = _detail_fingerprint(run_dir, state)
|
|
237
|
+
cached = getattr(state, "aggregation_cache", None)
|
|
238
|
+
if cached and cached[0] == fingerprint:
|
|
239
|
+
return cached[1]
|
|
240
|
+
cache_path = state_dir(run_dir) / "aggregation-inputs.json"
|
|
241
|
+
compressed_path = cache_path.with_suffix(".json.gz")
|
|
242
|
+
payload = None
|
|
243
|
+
if compressed_path.is_file() or cache_path.is_file():
|
|
244
|
+
stored_path = compressed_path if compressed_path.is_file() else cache_path
|
|
245
|
+
raw = stored_path.read_bytes()
|
|
246
|
+
metadata = state.get("aggregation_cache") or {}
|
|
247
|
+
if (metadata.get("fingerprint") == fingerprint and
|
|
248
|
+
metadata.get("content_digest") == hashlib.sha256(raw).hexdigest()):
|
|
249
|
+
cached = json.loads(gzip.decompress(raw) if stored_path == compressed_path else raw)
|
|
250
|
+
if cached["fingerprint"] == fingerprint:
|
|
251
|
+
payload = cached["records"], cached["edges"]
|
|
252
|
+
if payload is None:
|
|
253
|
+
units, dispositions = _semantic_units(run_dir, state)
|
|
254
|
+
payload = aggregation.inputs(units, dispositions, state["inventory"], _verified_context(run_dir, state))
|
|
255
|
+
serialized = json.dumps({"fingerprint": fingerprint, "records": payload[0], "edges": payload[1]},
|
|
256
|
+
ensure_ascii=False).encode("utf-8")
|
|
257
|
+
if len(serialized) > 10_000_000:
|
|
258
|
+
stored_path = compressed_path
|
|
259
|
+
temporary = compressed_path.with_suffix(".tmp")
|
|
260
|
+
temporary.write_bytes(gzip.compress(serialized, mtime=0))
|
|
261
|
+
os.replace(temporary, compressed_path)
|
|
262
|
+
cache_path.unlink(missing_ok=True)
|
|
263
|
+
else:
|
|
264
|
+
stored_path = cache_path
|
|
265
|
+
temporary = cache_path.with_suffix(".tmp")
|
|
266
|
+
temporary.write_bytes(serialized)
|
|
267
|
+
os.replace(temporary, cache_path)
|
|
268
|
+
compressed_path.unlink(missing_ok=True)
|
|
269
|
+
state["aggregation_cache"] = {"fingerprint": fingerprint,
|
|
270
|
+
"content_digest": hashlib.sha256(stored_path.read_bytes()).hexdigest()}
|
|
271
|
+
if isinstance(state, _RunState):
|
|
272
|
+
state.aggregation_cache = fingerprint, payload
|
|
273
|
+
return payload
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _wall_seconds(run_dir: Path, state: dict[str, Any]) -> float:
|
|
277
|
+
"""Time from the plan to this render; a run that was already complete keeps the time it finished in."""
|
|
278
|
+
try:
|
|
279
|
+
previous = json.loads((run_dir / "summary.json").read_text(encoding="utf-8"))
|
|
280
|
+
except (OSError, json.JSONDecodeError):
|
|
281
|
+
previous = {}
|
|
282
|
+
if previous.get("status") == "complete" and type(previous.get("wall_seconds")) in (int, float):
|
|
283
|
+
return previous["wall_seconds"]
|
|
284
|
+
return round(time.time() - epoch(state["created_at"]), 3)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _invalidate_tree(run_dir: Path, state: dict[str, Any]) -> None:
|
|
288
|
+
"""Drop the frozen skeleton, its naming tasks and their answers (the detail changed)."""
|
|
289
|
+
for tid in [tid for tid, task in state["tasks"].items() if task["kind"] in TREE_KINDS]:
|
|
290
|
+
del state["tasks"][tid]
|
|
291
|
+
(state_dir(run_dir) / "accepted" / f"{tid}.json").unlink(missing_ok=True)
|
|
292
|
+
_partial_path(run_dir, tid).unlink(missing_ok=True)
|
|
293
|
+
state.pop("skeleton", None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _prepare_tree(run_dir: Path, state: dict[str, Any]) -> None:
|
|
297
|
+
"""Once all detail and review is settled, freeze the skeleton and make the naming tasks.
|
|
298
|
+
|
|
299
|
+
The same detail finds the same skeleton in the run state; changed detail (a late module, a
|
|
300
|
+
repair) drops it and starts over."""
|
|
301
|
+
tasks = state["tasks"]
|
|
302
|
+
if not _done_modules(state) or not all(
|
|
303
|
+
_settled(state, task) for task in tasks.values() if task["kind"] in {"module", "repair", "review"}):
|
|
304
|
+
return
|
|
305
|
+
fingerprint = _detail_fingerprint(run_dir, state)
|
|
306
|
+
if (state.get("skeleton") or {}).get("input_digest") == fingerprint:
|
|
307
|
+
return
|
|
308
|
+
units, edges = _aggregation_inputs(run_dir, state)
|
|
309
|
+
if not units and not state.get("skeleton"):
|
|
310
|
+
return # an empty run: _auto_complete_empty_modules published its one-node tree
|
|
311
|
+
_invalidate_tree(run_dir, state)
|
|
312
|
+
if not units:
|
|
313
|
+
return
|
|
314
|
+
frozen = {**treename.freeze(units, edges), "input_digest": fingerprint}
|
|
315
|
+
state["skeleton"] = frozen
|
|
316
|
+
for number in range(len(frozen["batches"])):
|
|
317
|
+
tid = f"tree_name_{number + 1}"
|
|
318
|
+
tasks[tid] = _task(tid, "tree_name", batch=number)
|
|
319
|
+
parents = {node["parent"] for node in frozen["nodes"]}
|
|
320
|
+
leaves = sum(node["id"] not in parents for node in frozen["nodes"])
|
|
321
|
+
if leaves > 1:
|
|
322
|
+
tasks["tree_top"] = _task("tree_top", "tree_top")
|
|
323
|
+
log_event(run_dir, "queue", f"module skeleton frozen: {leaves} modules in {len(frozen['nodes'])} nodes; "
|
|
324
|
+
f"{len(frozen['batches'])} naming batch(es)" + (", then tree_top" if leaves > 1 else ""),
|
|
325
|
+
leaves=leaves, nodes=len(frozen["nodes"]), batches=len(frozen["batches"]))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _known_unit_ids(run_dir: Path, state: dict[str, Any], *, exclude: int | None = None) -> set[str]:
|
|
329
|
+
units, _ = _semantic_units(run_dir, state)
|
|
330
|
+
if exclude is None:
|
|
331
|
+
return set(units)
|
|
332
|
+
paths = set(state["modules"][exclude]["files"])
|
|
333
|
+
# A repair supersedes the module's own output, so that output's unit ids are
|
|
334
|
+
# not foreign collisions. Path-prefixed ids drop out via ``paths``; group
|
|
335
|
+
# (``grp:``) ids carry no path, so subtract the module's current output ids
|
|
336
|
+
# explicitly. Cross-module collisions are still reported.
|
|
337
|
+
own = _module_output(run_dir, state, exclude) or {}
|
|
338
|
+
own_ids = {unit.get("id") for unit in own.get("units") or [] if isinstance(unit, dict)}
|
|
339
|
+
return {uid for uid in units if uid.split("::", 1)[0] not in paths and uid not in own_ids}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _module_inventory_ids(state: dict[str, Any], module: dict[str, Any]) -> list[str]:
|
|
343
|
+
inventory = state.get("inventory") or {}
|
|
344
|
+
return [symbol["id"]
|
|
345
|
+
for path in module["files"]
|
|
346
|
+
for symbol in (inventory.get(path) or {}).get("symbols") or []]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _auto_complete_empty_modules(run_dir: Path, state: dict[str, Any]) -> list[str]:
|
|
350
|
+
"""Complete a known-empty batch mechanically, without a model call.
|
|
351
|
+
|
|
352
|
+
A module whose files hold no inventory id has no explainable detail: it may
|
|
353
|
+
only be empty files. Writing an empty, explicit accepted payload prevents
|
|
354
|
+
both a ``units must be a nonempty list`` rejection and the invented prose
|
|
355
|
+
that a forced retry produces. Comments and docstrings still produce chunks
|
|
356
|
+
and inventory ids, so they are never auto-completed."""
|
|
357
|
+
completed: list[str] = []
|
|
358
|
+
for row in state["modules"]:
|
|
359
|
+
number = row["number"]
|
|
360
|
+
task = state["tasks"].get(f"module_{number + 1}")
|
|
361
|
+
if task is None or task["status"] != "pending" or _module_inventory_ids(state, row):
|
|
362
|
+
continue
|
|
363
|
+
output = {"units": [], "dispositions": {},
|
|
364
|
+
"batch_notes": "No explainable detail: every file in this batch is empty and has "
|
|
365
|
+
"no inventory id, so no model call was made.",
|
|
366
|
+
"known_empty": True}
|
|
367
|
+
write_json(state_dir(run_dir) / "accepted" / f"module_{number + 1}.json", output)
|
|
368
|
+
task["status"], task["flags"] = "done", ["known_empty"]
|
|
369
|
+
task["output"] = f"host/accepted/module_{number + 1}.json"
|
|
370
|
+
log_event(run_dir, "accept", f"module_{number + 1} auto-completed: known-empty batch, no model call",
|
|
371
|
+
task=task["id"], flags=["known_empty"])
|
|
372
|
+
completed.append(task["id"])
|
|
373
|
+
review = state["tasks"].get(f"review_{number + 1}")
|
|
374
|
+
if review is not None and review["status"] == "pending":
|
|
375
|
+
write_json(state_dir(run_dir) / "accepted" / f"review_{number + 1}.json",
|
|
376
|
+
{"decision": "accepted", "issues": []})
|
|
377
|
+
review["status"], review["output"] = "done", f"host/accepted/review_{number + 1}.json"
|
|
378
|
+
log_event(run_dir, "accept", f"review_{number + 1} auto-accepted: known-empty module has no "
|
|
379
|
+
"prose to review", task=review["id"])
|
|
380
|
+
modules = [task for task in state["tasks"].values() if task["kind"] == "module"]
|
|
381
|
+
if modules and all(task["status"] in {"done", "failed"} for task in modules):
|
|
382
|
+
# Only once every reading batch is settled can "no units" mean an empty
|
|
383
|
+
# repository; before that the real units are still in flight.
|
|
384
|
+
units, _ = _semantic_units(run_dir, state)
|
|
385
|
+
if not units:
|
|
386
|
+
system = state["tasks"].get("system")
|
|
387
|
+
if system is not None and system["status"] == "pending":
|
|
388
|
+
write_json(state_dir(run_dir) / "accepted" / "system.json",
|
|
389
|
+
_mechanical_semantic_overview(state, {"nodes": []}))
|
|
390
|
+
system["status"], system["output"] = "done", "host/accepted/system.json"
|
|
391
|
+
log_event(run_dir, "accept", "system auto-completed mechanically: no units in this "
|
|
392
|
+
"run", task="system")
|
|
393
|
+
return completed
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _assert_paths_unchanged(state: dict[str, Any], paths: list[str]) -> None:
|
|
397
|
+
"""The script driver builds a prompt outside ``next_tasks``; a drifted source
|
|
398
|
+
must stop it exactly as the host dispatch refuses a drifted path."""
|
|
399
|
+
changed = _drift(state, paths)
|
|
400
|
+
if changed:
|
|
401
|
+
raise HostRunError(f"source changed since plan: {changed[0]}; start a new plan")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@contextlib.contextmanager
|
|
405
|
+
def locked_state(run_dir: Path) -> Iterator[dict[str, Any]]:
|
|
406
|
+
"""Yield the state under an exclusive lock; save it and STATUS.md on normal exit."""
|
|
407
|
+
host = state_dir(run_dir)
|
|
408
|
+
state_path = host / "state.json"
|
|
409
|
+
if not state_path.is_file():
|
|
410
|
+
raise HostRunError(f"no run state at {state_path}; run `cbe host plan` first")
|
|
411
|
+
with open(host / ".lock", "a+", encoding="utf-8") as handle:
|
|
412
|
+
try:
|
|
413
|
+
import fcntl
|
|
414
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
415
|
+
except ImportError: # pragma: no cover - non-POSIX hosts run one command at a time
|
|
416
|
+
pass
|
|
417
|
+
state = _RunState(json.loads(state_path.read_text(encoding="utf-8")))
|
|
418
|
+
if state.get("schema") != SCHEMA:
|
|
419
|
+
raise HostRunError(RUN_VERSION_ERROR)
|
|
420
|
+
if any(task["kind"] in {"names", "tree"} for task in state["tasks"].values()):
|
|
421
|
+
raise HostRunError(RUN_VERSION_ERROR)
|
|
422
|
+
yield state
|
|
423
|
+
state["updated_at"] = _now()
|
|
424
|
+
write_json(state_path, state)
|
|
425
|
+
(run_dir / "STATUS.md").write_text(_status_markdown(state), encoding="utf-8")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def log_event(run_dir: Path, kind: str, text: str, **fields: Any) -> None:
|
|
429
|
+
"""Append one machine record and one human-readable progress line."""
|
|
430
|
+
stamp = _now()
|
|
431
|
+
with (state_dir(run_dir) / "events.jsonl").open("a", encoding="utf-8") as stream:
|
|
432
|
+
stream.write(json.dumps({"at": stamp, "event": kind, **fields}, ensure_ascii=False) + "\n")
|
|
433
|
+
with (run_dir / "progress.log").open("a", encoding="utf-8") as stream:
|
|
434
|
+
stream.write(f"{stamp} {kind:<9} {text}\n")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _attempt_limit(task: dict[str, Any]) -> int:
|
|
438
|
+
return DETAIL_MAX_ATTEMPTS if task["kind"] in {"module", "repair"} else MAX_ATTEMPTS
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _task(task_id: str, kind: str, module: int | None = None, **extra: Any) -> dict[str, Any]:
|
|
442
|
+
return {"id": task_id, "kind": kind, "module": module, "status": "pending", "attempts": 0,
|
|
443
|
+
"errors": [], "flags": [], "lease": None, "leased_at": None, "submitted_at": None,
|
|
444
|
+
"seconds": None, "output": None, "usage": [], **extra}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _accepted(run_dir: Path, task_id: str) -> dict[str, Any] | None:
|
|
448
|
+
path = state_dir(run_dir) / "accepted" / f"{task_id}.json"
|
|
449
|
+
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _module_task(state: dict[str, Any], number: int) -> dict[str, Any]:
|
|
453
|
+
return state["tasks"][f"module_{number + 1}"]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _module_output(run_dir: Path, state: dict[str, Any], number: int) -> dict[str, Any] | None:
|
|
457
|
+
repair = state["tasks"].get(f"repair_{number + 1}")
|
|
458
|
+
if repair and repair["status"] == "done":
|
|
459
|
+
return _accepted(run_dir, repair["id"])
|
|
460
|
+
if _module_task(state, number)["status"] == "done":
|
|
461
|
+
return _accepted(run_dir, f"module_{number + 1}")
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _done_modules(state: dict[str, Any]) -> list[int]:
|
|
466
|
+
return [row["number"] for row in state["modules"] if _module_task(state, row["number"])["status"] == "done"]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _drift(state: dict[str, Any], paths: list[str] | None = None) -> list[str]:
|
|
470
|
+
"""Planned paths that are missing, unreadable, or no longer match their digest."""
|
|
471
|
+
repo = Path(state["repo"])
|
|
472
|
+
wanted = set(paths) if paths is not None else None
|
|
473
|
+
changed = []
|
|
474
|
+
for row in state["files"]:
|
|
475
|
+
if wanted is not None and row["path"] not in wanted:
|
|
476
|
+
continue
|
|
477
|
+
path = repo / row["path"]
|
|
478
|
+
try:
|
|
479
|
+
raw = path.read_bytes()
|
|
480
|
+
except OSError:
|
|
481
|
+
# Missing or unreadable: a host task must not list a path the
|
|
482
|
+
# subagent cannot actually read.
|
|
483
|
+
changed.append(row["path"])
|
|
484
|
+
continue
|
|
485
|
+
if hashlib.sha256(raw).hexdigest() != row["digest"]:
|
|
486
|
+
changed.append(row["path"])
|
|
487
|
+
return changed
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _title(state: dict[str, Any], task: dict[str, Any]) -> str:
|
|
491
|
+
return state["modules"][task["module"]]["title"] if task["module"] is not None else ""
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------- plan
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _runtime_environment() -> dict[str, Any]:
|
|
498
|
+
"""Record declared distribution requirements, including optional dependencies."""
|
|
499
|
+
import importlib.metadata as metadata
|
|
500
|
+
import platform
|
|
501
|
+
import re
|
|
502
|
+
import sys
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
requirements = metadata.requires("codebase-explorer") or []
|
|
506
|
+
except metadata.PackageNotFoundError:
|
|
507
|
+
# A source checkout can run without its own distribution metadata.
|
|
508
|
+
import tomllib
|
|
509
|
+
project = tomllib.loads((Path(__file__).parents[2] / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
|
510
|
+
requirements = list(project["dependencies"])
|
|
511
|
+
for extra in project.get("optional-dependencies", {}).values():
|
|
512
|
+
requirements.extend(extra)
|
|
513
|
+
dependencies = {}
|
|
514
|
+
for requirement in requirements:
|
|
515
|
+
name = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement).group()
|
|
516
|
+
try:
|
|
517
|
+
dependencies[name] = metadata.version(name)
|
|
518
|
+
except metadata.PackageNotFoundError:
|
|
519
|
+
dependencies[name] = None
|
|
520
|
+
return {"at": _now(), "python": sys.version, "platform": platform.platform(),
|
|
521
|
+
"cbe": __version__, "dependencies": dependencies}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def plan(repo: Path, run_dir: Path | None = None, *, review: str = DEFAULT_REVIEW, jobs: int = 4,
|
|
525
|
+
host: str = "other", driver: str = "host",
|
|
526
|
+
graph_path: Path | None = None, batch_target_tokens: int = batching.TARGET_TOKENS,
|
|
527
|
+
batch_max_tokens: int = batching.MAX_TOKENS) -> dict[str, Any]:
|
|
528
|
+
repo = repo.resolve()
|
|
529
|
+
if not repo.is_dir():
|
|
530
|
+
raise HostRunError("repository root is not a directory")
|
|
531
|
+
if review not in REVIEW_MODES:
|
|
532
|
+
raise HostRunError("review must be none, sample, or all")
|
|
533
|
+
if jobs < 1:
|
|
534
|
+
raise HostRunError("jobs must be positive")
|
|
535
|
+
if run_dir is None:
|
|
536
|
+
run_dir = repo / ".codebase-analysis" / "runs" / (
|
|
537
|
+
datetime.now(timezone.utc).strftime(f"{driver}-%Y%m%dT%H%M%SZ-") + secrets.token_hex(4))
|
|
538
|
+
run_dir = run_dir.resolve()
|
|
539
|
+
if (state_dir(run_dir) / "state.json").exists():
|
|
540
|
+
raise HostRunError("run directory already has a run; resume it or choose a fresh directory")
|
|
541
|
+
files = batching.scan(repo)
|
|
542
|
+
modules = batching.plan(files, target_tokens=batch_target_tokens, max_tokens=batch_max_tokens)
|
|
543
|
+
selected = [item.path for item in files]
|
|
544
|
+
context = _build_context(repo, selected, graph_path=graph_path)
|
|
545
|
+
symbols.ensure_chunk_coverage(context, repo, selected)
|
|
546
|
+
inventory = symbols.inventory_from_context(context, selected)
|
|
547
|
+
tasks: dict[str, dict[str, Any]] = {}
|
|
548
|
+
for module in modules:
|
|
549
|
+
tasks[f"module_{module.number + 1}"] = _task(f"module_{module.number + 1}", "module", module.number)
|
|
550
|
+
if review == "sample":
|
|
551
|
+
reviewed = [max(modules, key=lambda module: (module.tokens, -module.number))]
|
|
552
|
+
else:
|
|
553
|
+
reviewed = modules if review == "all" else []
|
|
554
|
+
for module in reviewed:
|
|
555
|
+
tasks[f"review_{module.number + 1}"] = _task(f"review_{module.number + 1}", "review", module.number)
|
|
556
|
+
tasks["system"] = _task("system", "system")
|
|
557
|
+
state = {
|
|
558
|
+
"schema": SCHEMA, "cbe_version": __version__, "run_tag": secrets.token_hex(3),
|
|
559
|
+
"repo": str(repo), "run_dir": str(run_dir), "host": host, "driver": driver,
|
|
560
|
+
"review": review, "jobs": jobs, "created_at": _now(), "updated_at": _now(), "status": "running",
|
|
561
|
+
"context": {"schema": context.get("schema"),
|
|
562
|
+
"graph_present": bool(context.get("graph")),
|
|
563
|
+
"graph_digest": context_digest(context["graph"]) if context.get("graph") else None},
|
|
564
|
+
"inventory": inventory,
|
|
565
|
+
"files": [{"path": item.path, "tokens": item.tokens, "digest": item.digest} for item in files],
|
|
566
|
+
"modules": [{"number": module.number, "bucket": module.bucket, "title": batching.fallback_title(module),
|
|
567
|
+
"files": [item.path for item in module.files]} for module in modules],
|
|
568
|
+
"tasks": tasks, "rendered": False, "verify": None,
|
|
569
|
+
"runtime_environments": [_runtime_environment()],
|
|
570
|
+
}
|
|
571
|
+
for code in FALLBACK_CODES:
|
|
572
|
+
fallbacks = [row for row in context.get("diagnostics", []) if row.get("code") == code]
|
|
573
|
+
if fallbacks:
|
|
574
|
+
state[code] = fallbacks
|
|
575
|
+
host_dir = state_dir(run_dir)
|
|
576
|
+
for name in ("tasks", "outbox", "accepted", "raw"):
|
|
577
|
+
(host_dir / name).mkdir(parents=True, exist_ok=True)
|
|
578
|
+
write_json(run_dir / "context.json", context)
|
|
579
|
+
state["context"]["raw_digest"] = hashlib.sha256((run_dir / "context.json").read_bytes()).hexdigest()
|
|
580
|
+
write_json(host_dir / "state.json", state)
|
|
581
|
+
(run_dir / "STATUS.md").write_text(_status_markdown(state), encoding="utf-8")
|
|
582
|
+
log_event(run_dir, "plan", f"{len(files)} files, {len(modules)} modules, {len(tasks)} tasks "
|
|
583
|
+
f"(review {review}, jobs {jobs}, host {host})",
|
|
584
|
+
modules=len(modules), files=len(files), review=review, jobs=jobs, host=host)
|
|
585
|
+
runs_root = repo / ".codebase-analysis" / "runs"
|
|
586
|
+
if run_dir.is_relative_to(runs_root):
|
|
587
|
+
(repo / ".codebase-analysis" / "host-latest").write_text(str(run_dir) + "\n", encoding="utf-8")
|
|
588
|
+
return {"run_dir": str(run_dir), "run_tag": state["run_tag"], "source_files": len(files),
|
|
589
|
+
"source_tokens": sum(item.tokens for item in files), "modules": len(modules),
|
|
590
|
+
"tasks": len(tasks), "review": review, "jobs": jobs,
|
|
591
|
+
"progress_log": str(run_dir / "progress.log"),
|
|
592
|
+
"next_action": f"cbe host next --run-dir {run_dir}"}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def update_graph(run_dir: Path, graph_path: Path) -> dict[str, Any]:
|
|
596
|
+
"""Replace only the dataflow layer of a completed run's frozen graph."""
|
|
597
|
+
render_error = None
|
|
598
|
+
with locked_state(run_dir) as state:
|
|
599
|
+
if state["status"] != "complete" or any(task["status"] != "done" for task in state["tasks"].values()):
|
|
600
|
+
raise HostRunError("only a completed run with no pending or failed tasks can update its graph")
|
|
601
|
+
frozen = state["context"]
|
|
602
|
+
if not frozen.get("graph_present"):
|
|
603
|
+
raise HostRunError("this run was planned without a graph; start a new plan with --graph")
|
|
604
|
+
context = _verified_context(run_dir, state)
|
|
605
|
+
try:
|
|
606
|
+
graph = source_context._load_graph(
|
|
607
|
+
graph_path, Path(state["repo"]), {row["path"]: row["digest"] for row in state["files"]})
|
|
608
|
+
read_graph_quality(graph)
|
|
609
|
+
except (ContextError, ValueError, OSError) as exc:
|
|
610
|
+
raise HostRunError(f"cannot read the new graph: {exc}") from exc
|
|
611
|
+
old_graph = context["graph"]
|
|
612
|
+
old_digest = frozen["graph_digest"]
|
|
613
|
+
new_digest = context_digest(graph)
|
|
614
|
+
if new_digest == old_digest:
|
|
615
|
+
return {"status": "nothing to update", "graph_digest": old_digest}
|
|
616
|
+
if graph.get("source_manifest") != old_graph.get("source_manifest"):
|
|
617
|
+
raise HostRunError("the new graph has a different source_manifest; start a new plan")
|
|
618
|
+
old_structure = ([n for n in old_graph["nodes"] if n.get("kind") != "dataflow_node"],
|
|
619
|
+
[e for e in old_graph["edges"] if e.get("kind") != "dataflow"])
|
|
620
|
+
new_structure = ([n for n in graph["nodes"] if n.get("kind") != "dataflow_node"],
|
|
621
|
+
[e for e in graph["edges"] if e.get("kind") != "dataflow"])
|
|
622
|
+
if new_structure != old_structure:
|
|
623
|
+
raise HostRunError("the new graph differs from this run's graph in more than dataflow; start a new plan")
|
|
624
|
+
stamp = _now()
|
|
625
|
+
context["graph"] = graph
|
|
626
|
+
write_json(run_dir / "context.json", context)
|
|
627
|
+
frozen["raw_digest"] = hashlib.sha256((run_dir / "context.json").read_bytes()).hexdigest()
|
|
628
|
+
frozen["graph_digest"] = new_digest
|
|
629
|
+
state.context = context
|
|
630
|
+
state.setdefault("graph_updates", []).append(
|
|
631
|
+
{"at": stamp, "old_graph_digest": old_digest, "new_graph_digest": new_digest})
|
|
632
|
+
# context.json and its digest in the state change together: save now, so that a failure
|
|
633
|
+
# while rendering cannot leave a new context beside the old digest.
|
|
634
|
+
write_json(state_dir(run_dir) / "state.json", state)
|
|
635
|
+
log_event(run_dir, "graph", "replaced the frozen graph without rerunning tasks",
|
|
636
|
+
old_graph_digest=old_digest, new_graph_digest=new_digest)
|
|
637
|
+
_render(run_dir, state)
|
|
638
|
+
render_error = state.get("render_error")
|
|
639
|
+
result = {"status": "updated", "old_graph_digest": old_digest,
|
|
640
|
+
"new_graph_digest": new_digest, "updated_at": stamp}
|
|
641
|
+
if render_error:
|
|
642
|
+
raise HostRunError(f"graph was updated but rendering failed: {render_error}")
|
|
643
|
+
return result
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def latest_run(repo: Path) -> Path:
|
|
647
|
+
pointer = repo.resolve() / ".codebase-analysis" / "host-latest"
|
|
648
|
+
if not pointer.is_file():
|
|
649
|
+
raise HostRunError(f"no run recorded under {repo}; pass --run-dir")
|
|
650
|
+
return Path(pointer.read_text(encoding="utf-8").strip())
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ---------------------------------------------------------------- scheduling
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _settled(state: dict[str, Any], task: dict[str, Any]) -> bool:
|
|
657
|
+
"""Done, failed, or unable to run because the module it depends on failed."""
|
|
658
|
+
if task["status"] in {"done", "failed"}:
|
|
659
|
+
return True
|
|
660
|
+
if task["kind"] in {"review", "repair"}:
|
|
661
|
+
return _module_task(state, task["module"])["status"] == "failed"
|
|
662
|
+
if task["kind"] == "system":
|
|
663
|
+
others = [other for other in state["tasks"].values() if other["kind"] != "system"]
|
|
664
|
+
return all(_settled(state, other) for other in others) and not _done_modules(state)
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _ready(state: dict[str, Any]) -> list[str]:
|
|
669
|
+
tasks = state["tasks"]
|
|
670
|
+
tree_names = [task for task in tasks.values() if task["kind"] == "tree_name"]
|
|
671
|
+
ready = []
|
|
672
|
+
for task_id, task in tasks.items():
|
|
673
|
+
if task["status"] != "pending":
|
|
674
|
+
continue
|
|
675
|
+
kind = task["kind"]
|
|
676
|
+
if kind in {"module", "repair", "tree_name"}:
|
|
677
|
+
# Detail prompts use only the frozen context and inventory; tree_name tasks exist only
|
|
678
|
+
# for a skeleton frozen after all detail and review settled.
|
|
679
|
+
ok = True
|
|
680
|
+
elif kind == "tree_top":
|
|
681
|
+
ok = all(other["status"] in {"done", "failed"} for other in tree_names)
|
|
682
|
+
elif kind == "review":
|
|
683
|
+
ok = _module_task(state, task["module"])["status"] == "done"
|
|
684
|
+
else:
|
|
685
|
+
others = [other for other in tasks.values() if other["kind"] != "system"]
|
|
686
|
+
ok = bool(_done_modules(state)) and bool(state.get("skeleton")) \
|
|
687
|
+
and all(_settled(state, other) for other in others)
|
|
688
|
+
if ok:
|
|
689
|
+
ready.append(task_id)
|
|
690
|
+
return sorted(ready, key=lambda task_id: (ORDER[tasks[task_id]["kind"]], tasks[task_id]["module"] or 0))
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _labels(state: dict[str, Any], task_id: str) -> dict[str, str]:
|
|
694
|
+
return {"description": f"cbe {state['run_tag']} {task_id}",
|
|
695
|
+
"codex_task_name": f"cbe_{state['run_tag']}_{task_id}"}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _detail_task(run_dir: Path, state: dict[str, Any], task: dict[str, Any], *, embed: bool,
|
|
699
|
+
with_diagnostics: bool, issues: list[str] | None = None) -> str:
|
|
700
|
+
"""The detail task for one reading batch, built from the verified frozen context."""
|
|
701
|
+
paths = list(state["modules"][task["module"]]["files"])
|
|
702
|
+
_assert_paths_unchanged(state, paths)
|
|
703
|
+
context = _verified_context(run_dir, state)
|
|
704
|
+
wanted = set(paths)
|
|
705
|
+
chunks = context.get("chunks") or []
|
|
706
|
+
diagnostics = [row for row in context.get("diagnostics") or [] if row.get("path") in wanted]
|
|
707
|
+
graph_file = None
|
|
708
|
+
graph = context.get("graph")
|
|
709
|
+
has_evidence = (projection.graph_state(graph)[0] == "provided"
|
|
710
|
+
and any(n.get("path") in wanted for n in graph.get("nodes", [])))
|
|
711
|
+
if not embed and state.get("driver") != "script" and has_evidence:
|
|
712
|
+
# The canonical detail file is shared with review and repair for this
|
|
713
|
+
# module: identical frozen evidence, independent of task id or issues.
|
|
714
|
+
target = state_dir(run_dir) / "tasks" / f"module_{task['module'] + 1}.graph.txt"
|
|
715
|
+
lines = projection.graph_prompt_lines(
|
|
716
|
+
context.get("graph"), paths, embed=False, inventory=state["inventory"],
|
|
717
|
+
chunks=chunks, source_tokens=None, max_tokens=None)
|
|
718
|
+
target.write_text("\n".join(lines), encoding="utf-8")
|
|
719
|
+
graph_file = str(target)
|
|
720
|
+
return prompts.detail_prompt(
|
|
721
|
+
paths, state["inventory"], chunks=chunks, source_root=Path(state["repo"]),
|
|
722
|
+
graph=context.get("graph"), graph_file=graph_file,
|
|
723
|
+
context_diagnostics=diagnostics if with_diagnostics else None, issues=issues, embed=embed)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _system_inputs(run_dir: Path, state: dict[str, Any]) -> tuple[list[dict], int, dict, dict]:
|
|
727
|
+
"""Use the same tree, evidence and character limit for prompt and acceptance."""
|
|
728
|
+
units, edges = _aggregation_inputs(run_dir, state)
|
|
729
|
+
tree = _semantic_tree(run_dir, state)
|
|
730
|
+
nodes = tree.get("nodes") or []
|
|
731
|
+
view = aggregation.relationship_view(tree, edges, _verified_context(run_dir, state).get("graph"))
|
|
732
|
+
return nodes, checks.system_limit(), view, units
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _body(run_dir: Path, state: dict[str, Any], task: dict[str, Any], *, embed: bool) -> tuple[str, str]:
|
|
736
|
+
"""Return (rules, task body); ``embed`` puts source in the prompt for tool-free completions.
|
|
737
|
+
|
|
738
|
+
Script completions embed each file once (``embed=True``); host subagents get
|
|
739
|
+
the absolute paths and the authoritative chunk line ranges instead, so the
|
|
740
|
+
source is never sent twice. A missing graph is stated as not provided.
|
|
741
|
+
"""
|
|
742
|
+
kind = task["kind"]
|
|
743
|
+
reading = prompts.NO_FILE_RULES if embed or state.get("driver") == "script" else prompts.FILE_TASK_RULES
|
|
744
|
+
no_source = prompts.NO_FILE_RULES
|
|
745
|
+
if not embed and state.get("driver") != "script":
|
|
746
|
+
no_source += (" Use your file-writing tool to write the result JSON. "
|
|
747
|
+
"Do not run shell commands, scripts, or interpreters, not even to assemble or check the JSON: "
|
|
748
|
+
"in the user's harness each one asks for permission, and this task does not need them.")
|
|
749
|
+
if kind in {"module", "repair"}:
|
|
750
|
+
return reading, _detail_task(run_dir, state, task, embed=embed, with_diagnostics=True,
|
|
751
|
+
issues=task.get("issues") if kind == "repair" else None)
|
|
752
|
+
if kind in TREE_KINDS:
|
|
753
|
+
return no_source, _tree_body(run_dir, state, task)
|
|
754
|
+
if kind == "review":
|
|
755
|
+
units = _module_output(run_dir, state, task["module"])
|
|
756
|
+
return reading, prompts.review_prompt(
|
|
757
|
+
units, _detail_task(run_dir, state, task, embed=embed, with_diagnostics=False))
|
|
758
|
+
nodes, limit, view, _ = _system_inputs(run_dir, state)
|
|
759
|
+
return no_source, prompts.system_prompt(nodes, limit, view)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _tree_names_for_top(run_dir: Path, state: dict[str, Any], tree: "treename.Tree", records: dict) -> dict[str, dict]:
|
|
763
|
+
"""Every leaf's record: the accepted one, or a program-made one when its naming task has not finished."""
|
|
764
|
+
names = _tree_names(run_dir, state)
|
|
765
|
+
for leaf in tree.leaves():
|
|
766
|
+
if leaf not in names:
|
|
767
|
+
names[leaf] = treename.program_record(records, tree.members(leaf))
|
|
768
|
+
return names
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _tree_body(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str:
|
|
772
|
+
records, edges = _aggregation_inputs(run_dir, state)
|
|
773
|
+
tree = treename.Tree(state["skeleton"]["nodes"])
|
|
774
|
+
if task["kind"] == "tree_name":
|
|
775
|
+
batch = state["skeleton"]["batches"][task["batch"]]
|
|
776
|
+
return prompts.tree_name_prompt(treename.name_blocks(batch, tree, records, edges))
|
|
777
|
+
names = _tree_names_for_top(run_dir, state, tree, records)
|
|
778
|
+
return prompts.tree_top_prompt(Path(state["repo"]).name, treename.top_blocks(tree, names, edges))
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _previous_error(task: dict[str, Any]) -> str:
|
|
782
|
+
if not task["errors"]:
|
|
783
|
+
return ""
|
|
784
|
+
return ("\n\nA previous attempt failed validation. Correct it now. Original error: "
|
|
785
|
+
+ task["errors"][-1][:2000] + "\n")
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _prompt(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str:
|
|
789
|
+
rules, body = _body(run_dir, state, task, embed=False)
|
|
790
|
+
outbox = state_dir(run_dir) / "outbox" / f"{task['id']}.json"
|
|
791
|
+
return (f"# Codebase Explorer task `{task['id']}` (run {state['run_tag']})\n\n{rules}\n\n"
|
|
792
|
+
f"When you are done, write exactly one JSON object (no Markdown fences, no commentary) to this "
|
|
793
|
+
f"file, creating or replacing it:\n{outbox}\n\nThen reply with the single line: DONE {task['id']}\n"
|
|
794
|
+
f"{_retry_note(run_dir, state, task) or _previous_error(task)}\n---\n\n{body}")
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def completion_prompt(run_dir: Path, task_id: str) -> str:
|
|
798
|
+
"""The same task as one tool-free completion: source is embedded."""
|
|
799
|
+
with locked_state(run_dir) as state:
|
|
800
|
+
task = state["tasks"][task_id]
|
|
801
|
+
_, body = _body(run_dir, state, task, embed=True)
|
|
802
|
+
prompt = prompts.TOOL_FREE_RULES + "\n\n" + body
|
|
803
|
+
note = _retry_note(run_dir, state, task)
|
|
804
|
+
if note is not None:
|
|
805
|
+
prompt += note # the first-ask text stays byte-identical, so the provider's prefix cache can serve it
|
|
806
|
+
elif task["errors"]:
|
|
807
|
+
prompt += "\n\nPrevious completion failed. Correct it now. Original error: " + task["errors"][-1][:2000]
|
|
808
|
+
previous = state_dir(run_dir) / "raw" / f"{task_id}.attempt-{task['attempts']}.txt"
|
|
809
|
+
if previous.is_file():
|
|
810
|
+
prompt += "; original_output:" + previous.read_text(encoding="utf-8")[:8000]
|
|
811
|
+
return prompt
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def next_tasks(run_dir: Path, *, limit: int | None = None, owner: str | None = None,
|
|
815
|
+
reclaim_after: int | None = None) -> dict[str, Any]:
|
|
816
|
+
with locked_state(run_dir) as state:
|
|
817
|
+
_verified_context(run_dir, state)
|
|
818
|
+
_auto_complete_empty_modules(run_dir, state)
|
|
819
|
+
_prepare_tree(run_dir, state)
|
|
820
|
+
now = time.time()
|
|
821
|
+
if reclaim_after is not None:
|
|
822
|
+
for task in state["tasks"].values():
|
|
823
|
+
if task["status"] == "leased" and now - epoch(task["lease"]["at"]) > reclaim_after:
|
|
824
|
+
task["status"], task["lease"] = "pending", None
|
|
825
|
+
log_event(run_dir, "reclaim", f"{task['id']} lease older than {reclaim_after}s returned to queue",
|
|
826
|
+
task=task["id"])
|
|
827
|
+
active = sum(task["status"] == "leased" for task in state["tasks"].values())
|
|
828
|
+
slots = max(0, state["jobs"] - active)
|
|
829
|
+
if limit is not None:
|
|
830
|
+
slots = min(slots, limit)
|
|
831
|
+
handed = []
|
|
832
|
+
queue = _ready(state)[:slots]
|
|
833
|
+
for task_id in queue:
|
|
834
|
+
task = state["tasks"][task_id]
|
|
835
|
+
if task["kind"] in {"module", "repair", "review"}:
|
|
836
|
+
changed = _drift(state, state["modules"][task["module"]]["files"])
|
|
837
|
+
if changed:
|
|
838
|
+
raise HostRunError("source missing or changed since plan: " + ", ".join(changed[:10])
|
|
839
|
+
+ "; start a new plan")
|
|
840
|
+
prompt_path = state_dir(run_dir) / "tasks" / f"{task_id}.md"
|
|
841
|
+
prompt_path.write_text(_prompt(run_dir, state, task), encoding="utf-8")
|
|
842
|
+
(state_dir(run_dir) / "outbox" / f"{task_id}.json").unlink(missing_ok=True)
|
|
843
|
+
stamp = _now()
|
|
844
|
+
task["status"], task["leased_at"] = "leased", stamp
|
|
845
|
+
task["lease"] = {"id": secrets.token_hex(4), "at": stamp, "owner": owner or state["host"]}
|
|
846
|
+
if task["kind"] == "system":
|
|
847
|
+
task["covered"] = _done_modules(state)
|
|
848
|
+
labels = _labels(state, task_id)
|
|
849
|
+
handed.append({
|
|
850
|
+
"task": task_id, "kind": task["kind"], "attempt": task["attempts"] + 1,
|
|
851
|
+
"title": _title(state, task), "prompt_file": str(prompt_path),
|
|
852
|
+
"output_file": str(state_dir(run_dir) / "outbox" / f"{task_id}.json"),
|
|
853
|
+
**labels,
|
|
854
|
+
"dispatch_message": (f"Codebase Explorer task {task_id}. Read the instruction file {prompt_path} "
|
|
855
|
+
f"and follow it exactly: it lists the files to read and the JSON file to "
|
|
856
|
+
f"write. Reply only with: DONE {task_id}"),
|
|
857
|
+
})
|
|
858
|
+
title = f" \"{_title(state, task)}\"" if task["module"] is not None else ""
|
|
859
|
+
log_event(run_dir, "claim", f"{task_id}{title} attempt {task['attempts'] + 1} -> {task['lease']['owner']}",
|
|
860
|
+
task=task_id, owner=task["lease"]["owner"])
|
|
861
|
+
summary = _summary(state)
|
|
862
|
+
return {"tasks": handed, **summary}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# ---------------------------------------------------------------- submit
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _checks(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> tuple[Any, Any]:
|
|
869
|
+
"""(strict check, normalizer) for a non-detail task; both mutate the value they accept."""
|
|
870
|
+
if task["kind"] == "review":
|
|
871
|
+
return checks.check_review, checks.normalize_review
|
|
872
|
+
_, limit, view, units = _system_inputs(run_dir, state)
|
|
873
|
+
shown = prompts.system_relationship_context(view)
|
|
874
|
+
options = dict(unit_ids=set(units), dependencies=shown["dependencies"],
|
|
875
|
+
has_candidates=bool(shown["candidate_flows"]))
|
|
876
|
+
repaired = False
|
|
877
|
+
def strict(value):
|
|
878
|
+
checks.check_system(value, limit, normalized=repaired, **options)
|
|
879
|
+
def normalize(value):
|
|
880
|
+
nonlocal repaired
|
|
881
|
+
flags = checks.normalize_system(value, limit, **options)
|
|
882
|
+
repaired = True
|
|
883
|
+
return flags
|
|
884
|
+
return strict, normalize
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
DETAIL_KINDS = {"module", "repair"}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _partial_path(run_dir: Path, task_id: str) -> Path:
|
|
891
|
+
return state_dir(run_dir) / "partial" / f"{task_id}.json"
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _load_partial(run_dir: Path, task_id: str) -> dict[str, Any]:
|
|
895
|
+
"""Units already accepted from earlier answers of one detail task."""
|
|
896
|
+
path = _partial_path(run_dir, task_id)
|
|
897
|
+
if path.is_file():
|
|
898
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
899
|
+
if task_id.startswith("tree_"):
|
|
900
|
+
return {"good": {}, "errors": {}}
|
|
901
|
+
return {"units": [], "flags": [], "invalid": [], "notes": "", "decoded": False}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _detail_table(state: dict[str, Any], task: dict[str, Any]) -> list[dict]:
|
|
905
|
+
return symbols.batch_table(state["modules"][task["module"]]["files"], state["inventory"])
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _gaps(run_dir: Path, state: dict[str, Any], table: list[dict], units: list[dict]) -> list[dict]:
|
|
909
|
+
"""Symbols with logic that no unit explains yet (see ``checks.find_gaps``)."""
|
|
910
|
+
wanted = {row["id"] for row in table if row["kind"] in checks.GAP_KINDS}
|
|
911
|
+
tokens = {chunk["id"]: count_text_tokens(chunk.get("text") or "")
|
|
912
|
+
for chunk in _verified_context(run_dir, state).get("chunks") or [] if chunk["id"] in wanted}
|
|
913
|
+
return checks.find_gaps(table, units, tokens)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _ground_answer(run_dir: Path, state: dict[str, Any], table: list[dict], decoded, final: bool) -> str | None:
|
|
917
|
+
"""Reject an answer whose explanations look attached to the wrong symbols (see ``grounding``).
|
|
918
|
+
|
|
919
|
+
One suspect explanation is not acted on: about 3% of correct explanations are suspect, and a
|
|
920
|
+
re-ask sends the whole batch again. Explanations attached to the wrong numbers show as many
|
|
921
|
+
suspects in one answer. On the last answer the suspects are kept and marked ``ungrounded``."""
|
|
922
|
+
chunks = {chunk["id"]: chunk for chunk in _verified_context(run_dir, state).get("chunks") or []}
|
|
923
|
+
by_id = {row["id"]: row for row in table}
|
|
924
|
+
names = grounding.repo_names(symbol["name"] for entry in (state.get("inventory") or {}).values()
|
|
925
|
+
for symbol in entry.get("symbols") or [])
|
|
926
|
+
suspects = {}
|
|
927
|
+
for unit in decoded.units:
|
|
928
|
+
ids = [unit["id"], *(unit.get("members") or [])]
|
|
929
|
+
text = [chunks[sid].get("text") or "" for sid in ids if sid in chunks]
|
|
930
|
+
text += [by_id[sid]["path"] for sid in ids if sid in by_id] + ids
|
|
931
|
+
missing = grounding.ungrounded(unit["explain"], "\n".join(text), names)
|
|
932
|
+
if missing:
|
|
933
|
+
suspects[unit["id"]] = missing
|
|
934
|
+
if len(suspects) < grounding.BATCH_MIN or len(suspects) < grounding.BATCH_SHARE * len(decoded.units):
|
|
935
|
+
return None
|
|
936
|
+
if final:
|
|
937
|
+
for unit in decoded.units:
|
|
938
|
+
if unit["id"] in suspects:
|
|
939
|
+
unit["ungrounded"] = suspects[unit["id"]]
|
|
940
|
+
decoded.flags += [f"ungrounded: {uid}: {', '.join(words)}" for uid, words in suspects.items()]
|
|
941
|
+
return None
|
|
942
|
+
for unit in decoded.units:
|
|
943
|
+
ids = {unit["id"], *(unit.get("members") or [])}
|
|
944
|
+
words = suspects.get(unit["id"])
|
|
945
|
+
decoded.invalid.append({
|
|
946
|
+
"h": [row["n"] for row in table if row["id"] in ids],
|
|
947
|
+
"why": (f"names {', '.join(words)}, not found in the source under these numbers" if words
|
|
948
|
+
else "discarded with the rest of that answer; write it again from the source under these numbers")})
|
|
949
|
+
decoded.units = []
|
|
950
|
+
return (f"ungrounded: {len(suspects)} explanation(s) name identifiers that are not in the source under "
|
|
951
|
+
"their own symbol numbers; they look attached to other numbers. None of that answer was kept. "
|
|
952
|
+
"Check every explanation against the source under its number and answer again.")
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _tree_nodes(state: dict[str, Any], task: dict[str, Any], tree: "treename.Tree") -> dict[str, str]:
|
|
956
|
+
"""``{label: node}`` of the nodes a tree task names."""
|
|
957
|
+
if task["kind"] == "tree_name":
|
|
958
|
+
return treename.name_context(state["skeleton"]["batches"][task["batch"]], tree)[0]
|
|
959
|
+
return treename.top_context(tree)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _tree_retry_note(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str:
|
|
963
|
+
partial = _load_partial(run_dir, task["id"])
|
|
964
|
+
labels = _tree_nodes(state, task, treename.Tree(state["skeleton"]["nodes"]))
|
|
965
|
+
pending = [label for label, node in labels.items() if node not in partial["good"]]
|
|
966
|
+
return prompts.tree_retry_note(pending, [m for node, rows in partial["errors"].items() for m in rows],
|
|
967
|
+
sorted(r["title"] for r in partial["good"].values()))
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _retry_note(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str | None:
|
|
971
|
+
"""The follow-up that a re-ask appends: for a detail task only the gaps, for a tree task only the
|
|
972
|
+
nodes that were rejected."""
|
|
973
|
+
if task["kind"] not in DETAIL_KINDS | set(TREE_KINDS) or not _partial_path(run_dir, task["id"]).is_file():
|
|
974
|
+
return None
|
|
975
|
+
if task["kind"] in TREE_KINDS:
|
|
976
|
+
return _tree_retry_note(run_dir, state, task)
|
|
977
|
+
partial = _load_partial(run_dir, task["id"])
|
|
978
|
+
table = _detail_table(state, task)
|
|
979
|
+
problem = (task["errors"][-1][:300] if task["errors"]
|
|
980
|
+
and task["errors"][-1].startswith(("schema_error", "ungrounded")) else None)
|
|
981
|
+
return prompts.gap_note(_gaps(run_dir, state, table, partial["units"]), partial["invalid"], problem)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _settle_detail(run_dir: Path, state: dict[str, Any], task: dict[str, Any], value: dict[str, Any] | None,
|
|
985
|
+
error: str | None, final: bool) -> tuple[str | None, dict[str, Any] | None, list[str]]:
|
|
986
|
+
"""Decode one detail answer and fold it into the task's accepted units.
|
|
987
|
+
|
|
988
|
+
Returns ``(error, payload, flags)``: ``payload`` is the internal result when the
|
|
989
|
+
batch is complete, or when this was the last answer (remaining gaps are indexed
|
|
990
|
+
without prose and flagged ``unexplained``). Otherwise ``error`` names what is
|
|
991
|
+
still missing and the decoded units stay saved for the re-ask."""
|
|
992
|
+
if value is None and not (error or "").startswith("schema_error"):
|
|
993
|
+
return error, None, [] # a provider-side failure: no answer to decode; saved units wait
|
|
994
|
+
table = _detail_table(state, task)
|
|
995
|
+
partial = _load_partial(run_dir, task["id"])
|
|
996
|
+
decoded = None
|
|
997
|
+
if value is not None:
|
|
998
|
+
try:
|
|
999
|
+
decoded = checks.decode_details(value, table, have=partial["units"],
|
|
1000
|
+
known_ids=_known_unit_ids(run_dir, state, exclude=task["module"]))
|
|
1001
|
+
except ValueError as exc:
|
|
1002
|
+
error = f"schema_error:{exc}"
|
|
1003
|
+
if decoded is not None:
|
|
1004
|
+
grounding_error = _ground_answer(run_dir, state, table, decoded, final)
|
|
1005
|
+
partial["units"] += decoded.units
|
|
1006
|
+
partial["flags"] += decoded.flags
|
|
1007
|
+
partial["invalid"] = decoded.invalid
|
|
1008
|
+
partial["notes"] = decoded.notes or partial["notes"]
|
|
1009
|
+
partial["decoded"] = True
|
|
1010
|
+
if grounding_error:
|
|
1011
|
+
_partial_path(run_dir, task["id"]).parent.mkdir(exist_ok=True)
|
|
1012
|
+
write_json(_partial_path(run_dir, task["id"]), partial)
|
|
1013
|
+
return grounding_error, None, []
|
|
1014
|
+
gaps = _gaps(run_dir, state, table, partial["units"])
|
|
1015
|
+
if decoded is not None and not gaps:
|
|
1016
|
+
# A rejected unit alone never earns a re-ask: if its symbols mattered they are gaps, and
|
|
1017
|
+
# a unit that names no symbol (a stray string in the list) has nothing to recover.
|
|
1018
|
+
error = None
|
|
1019
|
+
partial["flags"] += [f"invalid unit h={row['h']}: {row['why']}" for row in decoded.invalid]
|
|
1020
|
+
elif not final or not partial["decoded"]:
|
|
1021
|
+
if decoded is not None:
|
|
1022
|
+
_partial_path(run_dir, task["id"]).parent.mkdir(exist_ok=True)
|
|
1023
|
+
write_json(_partial_path(run_dir, task["id"]), partial)
|
|
1024
|
+
error = (f"incomplete: {len(gaps)} symbol(s) with logic still without a unit "
|
|
1025
|
+
f"{[row['n'] for row in gaps][:20]}; {len(decoded.invalid)} unit(s) rejected")
|
|
1026
|
+
return error, None, []
|
|
1027
|
+
else:
|
|
1028
|
+
error = None
|
|
1029
|
+
partial["flags"] += [f"invalid unit h={row['h']}: {row['why']}" for row in partial["invalid"]]
|
|
1030
|
+
partial["flags"] += [f"unexplained: {row['id']}" for row in gaps]
|
|
1031
|
+
_partial_path(run_dir, task["id"]).unlink(missing_ok=True)
|
|
1032
|
+
return None, checks.assemble_details(table, partial["units"], notes=partial["notes"]), partial["flags"]
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _settle_tree(run_dir: Path, state: dict[str, Any], task: dict[str, Any], value: dict[str, Any] | None,
|
|
1036
|
+
error: str | None, final: bool) -> tuple[str | None, dict[str, Any] | None, list[str]]:
|
|
1037
|
+
"""Decode one tree_name or tree_top answer node by node (see ``treename``).
|
|
1038
|
+
|
|
1039
|
+
Same contract as ``_settle_detail``: the good nodes are kept, a re-ask names only the rest, and
|
|
1040
|
+
after the last answer every node still unnamed gets a program-made title, so the task completes."""
|
|
1041
|
+
if value is None and not (error or "").startswith("schema_error"):
|
|
1042
|
+
return error, None, [] # a provider-side failure: no answer to decode; saved nodes wait
|
|
1043
|
+
records, _ = _aggregation_inputs(run_dir, state)
|
|
1044
|
+
tree = treename.Tree(state["skeleton"]["nodes"])
|
|
1045
|
+
labels = _tree_nodes(state, task, tree)
|
|
1046
|
+
partial = _load_partial(run_dir, task["id"])
|
|
1047
|
+
pending = set(labels.values()) - set(partial["good"])
|
|
1048
|
+
taken = {treename.normalize_title(r["title"]) for r in partial["good"].values()}
|
|
1049
|
+
problems: dict[str, list[str]] = {}
|
|
1050
|
+
if value is not None:
|
|
1051
|
+
try:
|
|
1052
|
+
if task["kind"] == "tree_name":
|
|
1053
|
+
good, problems = treename.decode_names(value, state["skeleton"]["batches"][task["batch"]], tree,
|
|
1054
|
+
pending, taken)
|
|
1055
|
+
else:
|
|
1056
|
+
good, problems = treename.decode_top(value, tree, _tree_names_for_top(run_dir, state, tree, records),
|
|
1057
|
+
pending, taken)
|
|
1058
|
+
partial["good"].update(good)
|
|
1059
|
+
except ValueError as exc:
|
|
1060
|
+
error = f"schema_error:{exc}"
|
|
1061
|
+
pending -= set(partial["good"])
|
|
1062
|
+
if not pending:
|
|
1063
|
+
error, flags = None, []
|
|
1064
|
+
elif not final:
|
|
1065
|
+
if partial["good"]:
|
|
1066
|
+
_partial_path(run_dir, task["id"]).parent.mkdir(exist_ok=True)
|
|
1067
|
+
write_json(_partial_path(run_dir, task["id"]), {**partial, "errors": problems})
|
|
1068
|
+
shown = "; ".join(m for rows in problems.values() for m in rows)[:300] or (error or "")
|
|
1069
|
+
return f"incomplete: {len(pending)} of {len(labels)} node(s) not named: {shown}", None, []
|
|
1070
|
+
else:
|
|
1071
|
+
error, flags = None, []
|
|
1072
|
+
for label, node in labels.items():
|
|
1073
|
+
if node in pending:
|
|
1074
|
+
partial["good"][node] = treename.program_record(records, tree.members(node))
|
|
1075
|
+
why = "; ".join(problems.get(node, [])) or "no usable answer"
|
|
1076
|
+
flags.append(f"program-made title for {label}: {why}"[:300])
|
|
1077
|
+
_partial_path(run_dir, task["id"]).unlink(missing_ok=True)
|
|
1078
|
+
return None, {"names": partial["good"]}, flags
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _usage_entry(usage: dict[str, Any] | None, attempt: int) -> dict[str, Any] | None:
|
|
1082
|
+
if not usage or not any(value is not None for value in usage.values()):
|
|
1083
|
+
return None
|
|
1084
|
+
return {"attempt": attempt, "basis": "reported", **{k: v for k, v in usage.items() if v is not None}}
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _apply_submit(run_dir: Path, state: dict[str, Any], task: dict[str, Any], result: Path,
|
|
1088
|
+
usage: dict[str, Any] | None, *, error: str | None = None,
|
|
1089
|
+
raw_text: str | None = None) -> dict[str, Any]:
|
|
1090
|
+
task["attempts"] += 1
|
|
1091
|
+
attempt = task["attempts"]
|
|
1092
|
+
limit = _attempt_limit(task)
|
|
1093
|
+
round_attempt = attempt - task.get("attempt_base", 0)
|
|
1094
|
+
final = round_attempt >= limit
|
|
1095
|
+
entry = _usage_entry(usage, attempt)
|
|
1096
|
+
if entry:
|
|
1097
|
+
task["usage"].append(entry)
|
|
1098
|
+
stamp = _now()
|
|
1099
|
+
task["submitted_at"] = stamp
|
|
1100
|
+
task["seconds"] = round(epoch(stamp) - epoch(task["leased_at"]), 1) if task.get("leased_at") else None
|
|
1101
|
+
raw = raw_text if error is not None else (result.read_text(encoding="utf-8") if result.is_file() else None)
|
|
1102
|
+
if error is None and raw is None:
|
|
1103
|
+
error = f"no result file at {result}"
|
|
1104
|
+
archive = state_dir(run_dir) / "raw"
|
|
1105
|
+
if raw is not None:
|
|
1106
|
+
(archive / f"{task['id']}.attempt-{attempt}.txt").write_text(raw, encoding="utf-8")
|
|
1107
|
+
value: dict[str, Any] | None = None
|
|
1108
|
+
flags: list[str] = []
|
|
1109
|
+
detail = task["kind"] in DETAIL_KINDS
|
|
1110
|
+
naming = task["kind"] in TREE_KINDS
|
|
1111
|
+
if not (detail or naming):
|
|
1112
|
+
strict, normalize = _checks(run_dir, state, task)
|
|
1113
|
+
if error is None:
|
|
1114
|
+
try:
|
|
1115
|
+
value = checks.json_object(raw or "")
|
|
1116
|
+
if not (detail or naming):
|
|
1117
|
+
strict(value)
|
|
1118
|
+
except (ValueError, TypeError) as exc:
|
|
1119
|
+
error, value = f"schema_error:{exc}", None
|
|
1120
|
+
if detail:
|
|
1121
|
+
error, value, flags = _settle_detail(run_dir, state, task, value, error, final)
|
|
1122
|
+
elif naming:
|
|
1123
|
+
error, value, flags = _settle_tree(run_dir, state, task, value, error, final)
|
|
1124
|
+
elif error is not None and final:
|
|
1125
|
+
# Answers without delivered source or with provider truncation must
|
|
1126
|
+
# never be resurrected by the final-attempt normalizer.
|
|
1127
|
+
undelivered = set(task.get("undelivered_attempts") or []) | set(task.get("unsafe_attempts") or [])
|
|
1128
|
+
if error.startswith(("source_not_delivered", "output_limit")):
|
|
1129
|
+
undelivered.add(attempt)
|
|
1130
|
+
for number in range(attempt, task.get("attempt_base", 0), -1):
|
|
1131
|
+
if number in undelivered:
|
|
1132
|
+
continue
|
|
1133
|
+
candidate = archive / f"{task['id']}.attempt-{number}.txt"
|
|
1134
|
+
if not candidate.is_file():
|
|
1135
|
+
continue
|
|
1136
|
+
try:
|
|
1137
|
+
parsed = checks.json_object(candidate.read_text(encoding="utf-8"))
|
|
1138
|
+
flags = normalize(parsed)
|
|
1139
|
+
strict(parsed)
|
|
1140
|
+
except (ValueError, TypeError):
|
|
1141
|
+
continue
|
|
1142
|
+
value = parsed
|
|
1143
|
+
task["normalized_from"] = error
|
|
1144
|
+
error = None
|
|
1145
|
+
break
|
|
1146
|
+
task["lease"] = None
|
|
1147
|
+
label = f"{task['id']}" + (f" \"{_title(state, task)}\"" if task["module"] is not None else "")
|
|
1148
|
+
timing = f" in {task['seconds']}s" if task["seconds"] is not None else ""
|
|
1149
|
+
if error is not None:
|
|
1150
|
+
task["errors"].append(error)
|
|
1151
|
+
if error.startswith("source_not_delivered"):
|
|
1152
|
+
task.setdefault("undelivered_attempts", []).append(attempt)
|
|
1153
|
+
if error.startswith("output_limit"):
|
|
1154
|
+
task.setdefault("unsafe_attempts", []).append(attempt)
|
|
1155
|
+
result.unlink(missing_ok=True)
|
|
1156
|
+
task["status"] = "failed" if final else "pending"
|
|
1157
|
+
if task["status"] == "failed":
|
|
1158
|
+
log_event(run_dir, "fail", f"{label} after {attempt} total attempts "
|
|
1159
|
+
f"({round_attempt}/{limit} this round){timing}: {error[:300]}; "
|
|
1160
|
+
"pending until the run is resumed", task=task["id"], error=error[:300],
|
|
1161
|
+
attempt=attempt, round_attempt=round_attempt, attempt_limit=limit)
|
|
1162
|
+
else:
|
|
1163
|
+
log_event(run_dir, "reject", f"{label} attempt {attempt} ({round_attempt}/{limit} this round)"
|
|
1164
|
+
f"{timing}: {error[:300]}; re-asking with this error",
|
|
1165
|
+
task=task["id"], attempt=attempt, error=error[:300],
|
|
1166
|
+
round_attempt=round_attempt, attempt_limit=limit)
|
|
1167
|
+
_maybe_render(run_dir, state, refresh=False)
|
|
1168
|
+
return {"accepted": False, "task": task["id"], "error": error[:1000],
|
|
1169
|
+
"attempts": attempt, "task_status": task["status"]}
|
|
1170
|
+
assert value is not None
|
|
1171
|
+
write_json(state_dir(run_dir) / "accepted" / f"{task['id']}.json", value)
|
|
1172
|
+
task["status"], task["flags"] = "done", flags
|
|
1173
|
+
task["output"] = f"host/accepted/{task['id']}.json"
|
|
1174
|
+
if flags:
|
|
1175
|
+
log_event(run_dir, "normalize", f"{label} attempt {attempt}: accepted after normalization: "
|
|
1176
|
+
+ "; ".join(flags)[:600], task=task["id"], flags=flags)
|
|
1177
|
+
log_event(run_dir, "accept", f"{label} attempt {attempt}{timing} -> {task['output']}", task=task["id"],
|
|
1178
|
+
attempt=attempt)
|
|
1179
|
+
if task["kind"] == "review" and value["decision"] == "needs_repair" and value["issues"]:
|
|
1180
|
+
repair_id = f"repair_{task['module'] + 1}"
|
|
1181
|
+
if repair_id not in state["tasks"]:
|
|
1182
|
+
state["tasks"][repair_id] = _task(repair_id, "repair", task["module"], issues=value["issues"])
|
|
1183
|
+
log_event(run_dir, "queue", f"{repair_id} for {len(value['issues'])} review finding(s)", task=repair_id)
|
|
1184
|
+
if state["review"] == "sample" and not state.get("review_escalated"):
|
|
1185
|
+
_escalate_review(run_dir, state, task, len(value["issues"]))
|
|
1186
|
+
elif task["kind"] in {"module", "repair"}:
|
|
1187
|
+
# The skeleton and the names describe a fixed detail snapshot: a repair or a late module
|
|
1188
|
+
# changes the unit set, so the tree is made again.
|
|
1189
|
+
if state.get("skeleton"):
|
|
1190
|
+
_invalidate_tree(run_dir, state)
|
|
1191
|
+
system = state["tasks"]["system"]
|
|
1192
|
+
if system["status"] == "done" and (task["kind"] == "repair"
|
|
1193
|
+
or task["module"] not in system.get("covered", [])):
|
|
1194
|
+
# The overview was written without this module's current output; ask
|
|
1195
|
+
# for a fresh one.
|
|
1196
|
+
system["status"], system["attempt_base"] = "pending", system["attempts"]
|
|
1197
|
+
log_event(run_dir, "queue", "system again so the overview covers the newly finished module", task="system")
|
|
1198
|
+
_maybe_render(run_dir, state, force=task["kind"] == "system")
|
|
1199
|
+
return {"accepted": True, "task": task["id"], "attempts": attempt, "task_status": "done",
|
|
1200
|
+
"normalized": bool(flags), "flags": flags}
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def submit(run_dir: Path, task_id: str, *, result: Path | None = None, usage: dict[str, Any] | None = None,
|
|
1204
|
+
error: str | None = None, raw_text: str | None = None) -> dict[str, Any]:
|
|
1205
|
+
with locked_state(run_dir) as state:
|
|
1206
|
+
# Evidence is only consumed against the frozen snapshot.
|
|
1207
|
+
_verified_context(run_dir, state)
|
|
1208
|
+
task = state["tasks"].get(task_id)
|
|
1209
|
+
if task is None:
|
|
1210
|
+
raise HostRunError(f"unknown task {task_id}")
|
|
1211
|
+
if task["status"] == "done":
|
|
1212
|
+
return {"accepted": True, "task": task_id, "already": True, **_summary(state)}
|
|
1213
|
+
if task["status"] == "failed":
|
|
1214
|
+
raise HostRunError(f"{task_id} failed after {task['attempts']} attempts; use `cbe host retry`")
|
|
1215
|
+
outcome = _apply_submit(run_dir, state, task, result or state_dir(run_dir) / "outbox" / f"{task_id}.json",
|
|
1216
|
+
usage, error=error, raw_text=raw_text)
|
|
1217
|
+
return {**outcome, **_summary(state)}
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def note_usage_unknown(run_dir: Path, task_id: str, attempt: int) -> None:
|
|
1221
|
+
"""Record a completed script-driver call whose host reported no usage at all.
|
|
1222
|
+
|
|
1223
|
+
Per-call source delivery could not be verified for it. The call is not
|
|
1224
|
+
failed on this, but the gap must be visible in STATUS.md."""
|
|
1225
|
+
with locked_state(run_dir) as state:
|
|
1226
|
+
state.setdefault("usage_unknown", []).append({"task": task_id, "attempt": attempt})
|
|
1227
|
+
log_event(run_dir, "usage", f"{task_id} attempt {attempt} reported no usage; "
|
|
1228
|
+
"per-call source delivery not verified", task=task_id, attempt=attempt)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
# ---------------------------------------------------------------- status / resume
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _fallback_fields(state: dict[str, Any]) -> dict[str, Any]:
|
|
1235
|
+
"""``<code>`` and ``<code>_count`` for every source fallback the plan recorded."""
|
|
1236
|
+
return {key: value for code in FALLBACK_CODES if (rows := state.get(code, []))
|
|
1237
|
+
for key, value in ((code + "_count", len(rows)), (code, rows))}
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _summary(state: dict[str, Any]) -> dict[str, Any]:
|
|
1241
|
+
tasks = list(state["tasks"].values())
|
|
1242
|
+
counts = dict(Counter(task["status"] for task in tasks))
|
|
1243
|
+
failed = [{"task": task["id"], "error": task["errors"][-1][:300]} for task in tasks
|
|
1244
|
+
if task["status"] == "failed"]
|
|
1245
|
+
leased = [task["id"] for task in tasks if task["status"] == "leased"]
|
|
1246
|
+
ready = _ready(state)
|
|
1247
|
+
run = state["run_dir"]
|
|
1248
|
+
if state["status"] == "complete":
|
|
1249
|
+
action = f"done; open {run}/docs/INDEX.md, then run `cbe host cost --run-dir {run}`"
|
|
1250
|
+
elif state["status"] == "partial" and state.get("partial_reason") == "source_not_embedded":
|
|
1251
|
+
action = (f"partial: source_not_embedded ({state.get('partial_detail', '')}); "
|
|
1252
|
+
"source changed or could not be embedded from the frozen plan. "
|
|
1253
|
+
"Create a new plan for the current source before generating documentation.")
|
|
1254
|
+
elif state["status"] == "partial" and state.get("partial_reason"):
|
|
1255
|
+
affected = ", ".join(row["task"] for row in failed) or state.get("partial_detail", "")
|
|
1256
|
+
again = (f"`cbe generate {state['repo']} --resume --run-dir {run}`" if state.get("driver") == "script"
|
|
1257
|
+
else f"`cbe host resume --run-dir {run}` and continue with `cbe host next`")
|
|
1258
|
+
action = (f"partial: {state['partial_reason']} ({state.get('partial_detail', '')}); "
|
|
1259
|
+
f"affected tasks: {affected}. Rejected answers were not published. Fix the cause, then {again}")
|
|
1260
|
+
elif state["status"] == "partial" and state.get("verify") and not state["verify"]["ok"] and not failed:
|
|
1261
|
+
names = ", ".join(row["check"] for row in state["verify"]["checks"] if not row["ok"])
|
|
1262
|
+
action = (f"partial: deterministic verification failed ({names}); inspect `cbe host verify --run-dir {run}`, "
|
|
1263
|
+
"fix the cause and verify again")
|
|
1264
|
+
elif state["status"] == "partial":
|
|
1265
|
+
again = (f"`cbe generate {state['repo']} --resume --run-dir {run}`" if state.get("driver") == "script"
|
|
1266
|
+
else f"`cbe host resume --run-dir {run}` and continue with `cbe host next`")
|
|
1267
|
+
action = (f"partial: docs are rendered and {len(failed)} task(s) are pending; fix the cause, then {again}")
|
|
1268
|
+
elif leased and not ready:
|
|
1269
|
+
action = "wait for the dispatched subagents, then submit each task"
|
|
1270
|
+
else:
|
|
1271
|
+
action = f"cbe host next --run-dir {run}"
|
|
1272
|
+
unknown = [f"{row['task']}#attempt-{row['attempt']}" for row in state.get("usage_unknown") or []]
|
|
1273
|
+
return {"status": state["status"], "counts": counts, "leased": leased, "failed": failed,
|
|
1274
|
+
"ready": len(ready), "next_action": action, "usage_unknown": unknown,
|
|
1275
|
+
**_fallback_fields(state)}
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def status(run_dir: Path) -> dict[str, Any]:
|
|
1279
|
+
with locked_state(run_dir) as state:
|
|
1280
|
+
rows = [{"task": task["id"], "title": _title(state, task), "status": task["status"],
|
|
1281
|
+
"attempts": task["attempts"], "seconds": task["seconds"], "output": task["output"],
|
|
1282
|
+
"flags": len(task["flags"])} for task in state["tasks"].values()]
|
|
1283
|
+
return {"run_dir": state["run_dir"], "run_tag": state["run_tag"], "review": state["review"],
|
|
1284
|
+
"jobs": state["jobs"], "modules": len(state["modules"]), "tasks": rows,
|
|
1285
|
+
"progress_log": str(run_dir / "progress.log"), **_summary(state)}
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def resume(run_dir: Path, *, owner: str | None = None, retry_failed: bool = True,
|
|
1289
|
+
graph_path: Path | None = None) -> dict[str, Any]:
|
|
1290
|
+
"""Continue a run from its records: adopt finished outbox files, release lost
|
|
1291
|
+
leases, and (by default) give failed tasks fresh attempts.
|
|
1292
|
+
|
|
1293
|
+
A ``graph_path`` passed on resume must be the same graph the plan froze; a
|
|
1294
|
+
different graph is rejected instead of silently ignored."""
|
|
1295
|
+
with locked_state(run_dir) as state:
|
|
1296
|
+
_verified_context(run_dir, state)
|
|
1297
|
+
state.setdefault("runtime_environments", []).append(_runtime_environment())
|
|
1298
|
+
_auto_complete_empty_modules(run_dir, state)
|
|
1299
|
+
if graph_path is not None:
|
|
1300
|
+
frozen = state.get("context") or {}
|
|
1301
|
+
if not frozen.get("graph_present"):
|
|
1302
|
+
raise HostRunError("this run was planned without a graph; --graph cannot be added on resume")
|
|
1303
|
+
try:
|
|
1304
|
+
provided = json.loads(Path(graph_path).read_text(encoding="utf-8"))
|
|
1305
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1306
|
+
raise HostRunError(f"cannot read the --graph passed on resume: {exc}") from exc
|
|
1307
|
+
if context_digest(provided) != frozen.get("graph_digest"):
|
|
1308
|
+
raise HostRunError("the --graph passed on resume differs from the frozen graph; "
|
|
1309
|
+
"start a new plan to change the graph")
|
|
1310
|
+
if owner:
|
|
1311
|
+
state["host"] = owner
|
|
1312
|
+
changed = _drift(state)
|
|
1313
|
+
submitted, released, retried = [], [], []
|
|
1314
|
+
for task in list(state["tasks"].values()):
|
|
1315
|
+
if task["status"] != "leased":
|
|
1316
|
+
continue
|
|
1317
|
+
outbox = state_dir(run_dir) / "outbox" / f"{task['id']}.json"
|
|
1318
|
+
if outbox.is_file():
|
|
1319
|
+
submitted.append(_apply_submit(run_dir, state, task, outbox, None))
|
|
1320
|
+
else:
|
|
1321
|
+
task["status"], task["lease"] = "pending", None
|
|
1322
|
+
released.append(task["id"])
|
|
1323
|
+
if retry_failed and not changed:
|
|
1324
|
+
for task in state["tasks"].values():
|
|
1325
|
+
if task["status"] == "failed":
|
|
1326
|
+
task["status"], task["attempt_base"] = "pending", task["attempts"]
|
|
1327
|
+
retried.append(task["id"])
|
|
1328
|
+
if (released or retried) and state["status"] == "partial":
|
|
1329
|
+
state["status"] = "running"
|
|
1330
|
+
limits = {tid: _attempt_limit(state["tasks"][tid]) for tid in retried}
|
|
1331
|
+
log_event(run_dir, "resume", f"by {owner or state['host']}: adopted {[row['task'] for row in submitted]}, "
|
|
1332
|
+
f"released {released}, retrying {retried}"
|
|
1333
|
+
+ (f"; fresh attempts {limits}" if limits else "")
|
|
1334
|
+
+ (f"; {len(changed)} source file(s) changed" if changed else ""),
|
|
1335
|
+
submitted=[row["task"] for row in submitted], released=released, retried=retried,
|
|
1336
|
+
**({"attempt_limits": limits} if limits else {}))
|
|
1337
|
+
_maybe_render(run_dir, state, refresh=False)
|
|
1338
|
+
return {"submitted": submitted, "released": released, "retried": retried, "changed_sources": changed[:50],
|
|
1339
|
+
**({"warning": "sources changed since plan; start a new plan"} if changed else {}),
|
|
1340
|
+
**_summary(state)}
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def retry(run_dir: Path, task_id: str) -> dict[str, Any]:
|
|
1344
|
+
with locked_state(run_dir) as state:
|
|
1345
|
+
chosen = [task for task in state["tasks"].values()
|
|
1346
|
+
if task["status"] == "failed" and task_id in ("all", task["id"])]
|
|
1347
|
+
if not chosen:
|
|
1348
|
+
raise HostRunError(f"{task_id} is not a failed task")
|
|
1349
|
+
for task in chosen:
|
|
1350
|
+
# Keep the cumulative count: raw archives and cost rows stay per attempt.
|
|
1351
|
+
task["status"], task["attempt_base"] = "pending", task["attempts"]
|
|
1352
|
+
log_event(run_dir, "retry", f"{task['id']} gets {_attempt_limit(task)} fresh attempts",
|
|
1353
|
+
task=task["id"], attempt_limit=_attempt_limit(task))
|
|
1354
|
+
if state["status"] == "partial":
|
|
1355
|
+
state["status"] = "running"
|
|
1356
|
+
return _summary(state)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def release(run_dir: Path, task_id: str | None = None) -> dict[str, Any]:
|
|
1360
|
+
with locked_state(run_dir) as state:
|
|
1361
|
+
released = []
|
|
1362
|
+
for task in state["tasks"].values():
|
|
1363
|
+
if task["status"] == "leased" and task_id in (None, task["id"]):
|
|
1364
|
+
task["status"], task["lease"] = "pending", None
|
|
1365
|
+
released.append(task["id"])
|
|
1366
|
+
if released:
|
|
1367
|
+
log_event(run_dir, "release", f"returned {released} to the queue", tasks=released)
|
|
1368
|
+
return {"released": released, **_summary(state)}
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
# ---------------------------------------------------------------- render / verify
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _maybe_render(run_dir: Path, state: dict[str, Any], *, force: bool = False, refresh: bool = True) -> None:
|
|
1375
|
+
"""Render when the overview arrives, when nothing more can run, or to refresh rendered docs."""
|
|
1376
|
+
terminal = all(_settled(state, task) for task in state["tasks"].values())
|
|
1377
|
+
if force or terminal or (refresh and state.get("rendered")):
|
|
1378
|
+
_render(run_dir, state)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _mechanical_semantic_overview(state: dict[str, Any], tree: dict[str, Any]) -> dict[str, str]:
|
|
1382
|
+
nodes = tree.get("nodes") or []
|
|
1383
|
+
return {"overview": (f"This map covers {len(state['files'])} source files in {len(nodes)} semantic "
|
|
1384
|
+
"nodes. The model-written overview is not available yet, so this summary is "
|
|
1385
|
+
"generated mechanically from the responsibility tree.")}
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _escalate_review(run_dir: Path, state: dict[str, Any], task: dict[str, Any], issues: int) -> None:
|
|
1389
|
+
"""A sampled batch with findings means this run's writing is not reliable: review every batch.
|
|
1390
|
+
|
|
1391
|
+
Acceptance sampling's tightened inspection: a clean sample costs nothing extra; a sample with
|
|
1392
|
+
defects pays for full review. Batches without units (known empty) have nothing to review."""
|
|
1393
|
+
added = []
|
|
1394
|
+
for module in state["modules"]:
|
|
1395
|
+
review_id = f"review_{module['number'] + 1}"
|
|
1396
|
+
if review_id in state["tasks"] or "known_empty" in _module_task(state, module["number"])["flags"]:
|
|
1397
|
+
continue
|
|
1398
|
+
state["tasks"][review_id] = _task(review_id, "review", module["number"])
|
|
1399
|
+
added.append(review_id)
|
|
1400
|
+
state["review_escalated"] = {"from": task["id"], "issues": issues, "added": added}
|
|
1401
|
+
log_event(run_dir, "queue", f"{len(added)} more review(s): the sampled {task['id']} reported {issues} "
|
|
1402
|
+
"issue(s), so every batch is reviewed", task=task["id"], added=added)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _review_note(run_dir: Path, state: dict[str, Any]) -> str:
|
|
1406
|
+
"""One RUN.md line: which batches a second model read, what it found, what was not read."""
|
|
1407
|
+
batches = [m for m in state["modules"] if "known_empty" not in _module_task(state, m["number"])["flags"]]
|
|
1408
|
+
review = _semantic_review_summary(run_dir, state)
|
|
1409
|
+
done, found = review["reviewed"], sum(len(row["issues"]) for row in review["reviewed"])
|
|
1410
|
+
if state["review"] == "none":
|
|
1411
|
+
return "Review: none; no batch was read by a second model."
|
|
1412
|
+
escalated = state.get("review_escalated")
|
|
1413
|
+
if escalated:
|
|
1414
|
+
return (f"Review: the sampled batch ({escalated['from']}) reported {escalated['issues']} issue(s), "
|
|
1415
|
+
f"so every batch was reviewed: {len(done)} of {len(batches)} reviewed, {found} issue(s) in total"
|
|
1416
|
+
+ (f", {len(review['pending'])} still pending." if review["pending"] else "."))
|
|
1417
|
+
if state["review"] == "sample":
|
|
1418
|
+
if not done:
|
|
1419
|
+
return "Review: one sampled batch, not reviewed yet."
|
|
1420
|
+
row = done[0]
|
|
1421
|
+
units = len((_module_output(run_dir, state, row["module"]) or {}).get("units") or [])
|
|
1422
|
+
return (f"Review: sampled batch module_{row['module'] + 1} ({units} units), {len(row['issues'])} issue(s); "
|
|
1423
|
+
f"the other {len(batches) - 1} batch(es) were not reviewed.")
|
|
1424
|
+
return (f"Review: {len(done)} of {len(batches)} batches reviewed, {found} issue(s) in total"
|
|
1425
|
+
+ (f", {len(review['pending'])} still pending." if review["pending"] else "."))
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _semantic_review_summary(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
|
|
1429
|
+
"""A separate record of the independent semantic review, not a validity score.
|
|
1430
|
+
|
|
1431
|
+
``deterministic_checks_ok`` reports structure and coverage. A review clears
|
|
1432
|
+
only the module whose source a fresh reviewer read; it never proves the
|
|
1433
|
+
whole map, and ``needs_repair`` findings are listed, not hidden."""
|
|
1434
|
+
reviewed, pending = [], []
|
|
1435
|
+
for module in state["modules"]:
|
|
1436
|
+
task_id = f"review_{module['number'] + 1}"
|
|
1437
|
+
task = state["tasks"].get(task_id)
|
|
1438
|
+
if task is None:
|
|
1439
|
+
continue
|
|
1440
|
+
if task["status"] == "done":
|
|
1441
|
+
output = _accepted(run_dir, task_id) or {}
|
|
1442
|
+
repair_id = f"repair_{module['number'] + 1}"
|
|
1443
|
+
repair = state["tasks"].get(repair_id)
|
|
1444
|
+
reviewed.append({"module": module["number"], "decision": output.get("decision"),
|
|
1445
|
+
"issues": output.get("issues") or [],
|
|
1446
|
+
"repair": repair["status"] if repair else None})
|
|
1447
|
+
else:
|
|
1448
|
+
pending.append(task_id)
|
|
1449
|
+
outcomes: dict[str, int] = {}
|
|
1450
|
+
for row in reviewed:
|
|
1451
|
+
outcomes[row["decision"]] = outcomes.get(str(row["decision"]), 0) + 1
|
|
1452
|
+
return {"mode": state.get("review"), "reviewed": reviewed, "outcomes": outcomes,
|
|
1453
|
+
"pending": pending, "escalated": state.get("review_escalated"),
|
|
1454
|
+
"note": "an independent reviewer read the source for each reviewed batch; "
|
|
1455
|
+
"this is not a whole-map proof"}
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _unexplained(state: dict[str, Any]) -> list[dict[str, str]]:
|
|
1459
|
+
"""Symbols that kept no prose because no answer explained them (flag ``unexplained: <id>``)."""
|
|
1460
|
+
return [{"task": task["id"], "symbol": flag.removeprefix("unexplained: ")}
|
|
1461
|
+
for task in sorted(state["tasks"].values(), key=lambda row: row["id"])
|
|
1462
|
+
for flag in task.get("flags") or [] if flag.startswith("unexplained: ")]
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _basis(run_dir: Path, state: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
|
1466
|
+
"""The recorded inputs behind this rendering; never infer an unrecorded model."""
|
|
1467
|
+
records = [record for task in state["tasks"].values()
|
|
1468
|
+
for record in [*(task.get("usage") or []), *(task.get("exact_usage") or [])]]
|
|
1469
|
+
models = {part.strip() for record in records for part in str(record.get("model") or "").split(",")
|
|
1470
|
+
if part.strip()}
|
|
1471
|
+
for path in (run_dir / "raw").glob("*.api.json"): # written by `cbe generate` for every call
|
|
1472
|
+
try:
|
|
1473
|
+
actual = json.loads(path.read_text(encoding="utf-8")).get("actual_model")
|
|
1474
|
+
except (OSError, ValueError):
|
|
1475
|
+
continue
|
|
1476
|
+
if isinstance(actual, str) and actual:
|
|
1477
|
+
models.add(actual)
|
|
1478
|
+
source_rows = [{"path": row["path"], "digest": row["digest"]} for row in state["files"]]
|
|
1479
|
+
source_digest = hashlib.sha256(json.dumps(
|
|
1480
|
+
source_rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
1481
|
+
graph = context.get("graph")
|
|
1482
|
+
updates = state.get("graph_updates") or []
|
|
1483
|
+
graph_basis = None if graph is None else {
|
|
1484
|
+
"digest": state["context"]["graph_digest"], "backend": graph.get("backend"),
|
|
1485
|
+
"edge_counts": dict(sorted(Counter(edge.get("kind") for edge in graph.get("edges") or []).items())),
|
|
1486
|
+
"updated": bool(updates), "updated_at": updates[-1]["at"] if updates else None}
|
|
1487
|
+
return {"cbe_version": state["cbe_version"], "models": sorted(models),
|
|
1488
|
+
"source": {"files": len(source_rows), "sha256": source_digest}, "graph": graph_basis}
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _render(run_dir: Path, state: dict[str, Any]) -> None:
|
|
1492
|
+
try:
|
|
1493
|
+
context = _verified_context(run_dir, state)
|
|
1494
|
+
_auto_complete_empty_modules(run_dir, state)
|
|
1495
|
+
tree = _semantic_tree(run_dir, state)
|
|
1496
|
+
except (HostRunError, symbols.SemanticError) as exc:
|
|
1497
|
+
state["render_error"] = str(exc)
|
|
1498
|
+
log_event(run_dir, "render", f"failed: {exc}", error=str(exc))
|
|
1499
|
+
return
|
|
1500
|
+
units, dispositions = _semantic_units(run_dir, state)
|
|
1501
|
+
pending = [f"module_{row['number'] + 1}" for row in state["modules"]
|
|
1502
|
+
if _module_output(run_dir, state, row["number"]) is None]
|
|
1503
|
+
index_notes: list[str] = []
|
|
1504
|
+
open_tree = [tid for tid, task in state["tasks"].items() if task["kind"] in TREE_KINDS and task["status"] != "done"]
|
|
1505
|
+
if open_tree:
|
|
1506
|
+
index_notes.append("Module names come from the program (frequent words and the main directory) "
|
|
1507
|
+
"where these tasks are not finished: " + ", ".join(open_tree)
|
|
1508
|
+
+ "; resume the run to finish them.")
|
|
1509
|
+
elif units and not state.get("skeleton"):
|
|
1510
|
+
index_notes.append("The module tree is a program grouping with program-made names: it is not frozen "
|
|
1511
|
+
"yet because reading is not finished.")
|
|
1512
|
+
made = sum(1 for node in tree.get("nodes") or [] if node.get("note"))
|
|
1513
|
+
if made:
|
|
1514
|
+
index_notes.append(f"{made} module title(s) are program-made (frequent words and main directory) "
|
|
1515
|
+
"because no usable model name was accepted for them.")
|
|
1516
|
+
system_task = state["tasks"]["system"]
|
|
1517
|
+
system = _accepted(run_dir, "system")
|
|
1518
|
+
if system is None:
|
|
1519
|
+
index_notes.append("The overview is mechanical because the `system` task is "
|
|
1520
|
+
+ ("failed; resume the run to retry it." if system_task["status"] == "failed"
|
|
1521
|
+
else "not finished yet."))
|
|
1522
|
+
else:
|
|
1523
|
+
if system_task["status"] != "done":
|
|
1524
|
+
index_notes.append("The overview was written before some modules finished; it will update "
|
|
1525
|
+
"when the resumed `system` task completes.")
|
|
1526
|
+
index_notes += [f"CBE normalized the overview after a second invalid answer: {flag}"
|
|
1527
|
+
for flag in system_task["flags"]]
|
|
1528
|
+
index_notes.append(_review_note(run_dir, state))
|
|
1529
|
+
if pending:
|
|
1530
|
+
index_notes.append(f"{len(pending)} module page(s) are pending: {', '.join(pending)}.")
|
|
1531
|
+
known_empty = sorted(path for path, entry in (state.get("inventory") or {}).items()
|
|
1532
|
+
if entry.get("known_empty"))
|
|
1533
|
+
if known_empty:
|
|
1534
|
+
index_notes.append("Known-empty files (no source text, no inventory id, no unit, no model "
|
|
1535
|
+
"call): " + ", ".join(known_empty) + ".")
|
|
1536
|
+
normalized = sorted(task["id"] for task in state["tasks"].values()
|
|
1537
|
+
if task["flags"] and "known_empty" not in task["flags"])
|
|
1538
|
+
if normalized:
|
|
1539
|
+
index_notes.append("Accepted with repairs or gaps (see each task's flags in host/state.json): "
|
|
1540
|
+
+ ", ".join(normalized) + ".")
|
|
1541
|
+
unexplained_rows = _unexplained(state)
|
|
1542
|
+
unexplained: dict[str, int] = {}
|
|
1543
|
+
for row in unexplained_rows:
|
|
1544
|
+
task = state["tasks"][row["task"]]
|
|
1545
|
+
repair = (state["tasks"].get(f"repair_{task['module'] + 1}") if task["kind"] == "module" else None)
|
|
1546
|
+
if task["status"] == "done" and not (repair and repair["status"] == "done"):
|
|
1547
|
+
unexplained[row["task"]] = unexplained.get(row["task"], 0) + 1
|
|
1548
|
+
for tid, count in sorted(unexplained.items()):
|
|
1549
|
+
index_notes.append(f"{tid}: {count} function/method/class symbol(s) were left without prose "
|
|
1550
|
+
"because no model answer explained them.")
|
|
1551
|
+
basis = _basis(run_dir, state, context)
|
|
1552
|
+
try:
|
|
1553
|
+
meta = docs.render(run_dir=run_dir, source_root=Path(state["repo"]),
|
|
1554
|
+
inventory=state.get("inventory") or {}, units=units,
|
|
1555
|
+
dispositions=dispositions, tree=tree, index_notes=index_notes,
|
|
1556
|
+
system=system or _mechanical_semantic_overview(state, tree),
|
|
1557
|
+
graph=context.get("graph"), context=context, basis=basis,
|
|
1558
|
+
source_tokens=sum(row["tokens"] for row in state["files"]),
|
|
1559
|
+
languages={row["path"]: row.get("language") or "unknown" for row in context["files"]})
|
|
1560
|
+
except symbols.SemanticError as exc:
|
|
1561
|
+
state["render_error"] = str(exc)
|
|
1562
|
+
log_event(run_dir, "render", f"failed: {exc}", error=str(exc))
|
|
1563
|
+
return
|
|
1564
|
+
state.pop("render_error", None)
|
|
1565
|
+
state["published"] = {"units": meta["units_rendered"], "nodes": meta["nodes"]}
|
|
1566
|
+
reason = state.get("partial_reason")
|
|
1567
|
+
complete = all(task["status"] == "done" for task in state["tasks"].values()) and not reason
|
|
1568
|
+
state["status"], state["rendered"] = ("complete" if complete else "partial"), True
|
|
1569
|
+
if not complete and not all(_settled(state, task) for task in state["tasks"].values()):
|
|
1570
|
+
state["status"] = "running"
|
|
1571
|
+
published_pages = meta["nodes"] + 1
|
|
1572
|
+
summary = {"status": state["status"], "driver": state.get("driver", "host"), "host": state["host"],
|
|
1573
|
+
"source_files": len(state["files"]),
|
|
1574
|
+
"source_tokens": sum(row["tokens"] for row in state["files"]),
|
|
1575
|
+
"modules": len(state["modules"]), "units": meta["units_rendered"],
|
|
1576
|
+
"semantic_nodes": meta["nodes"], "published_pages": published_pages,
|
|
1577
|
+
"pending": pending + open_tree + (["system"] if system_task["status"] != "done" else []),
|
|
1578
|
+
"normalized": normalized,
|
|
1579
|
+
"unexplained_count": len(unexplained_rows),
|
|
1580
|
+
"unexplained": unexplained_rows,
|
|
1581
|
+
"known_empty": sorted(task["id"] for task in state["tasks"].values()
|
|
1582
|
+
if "known_empty" in task["flags"]),
|
|
1583
|
+
"wall_seconds": _wall_seconds(run_dir, state), "basis": basis,
|
|
1584
|
+
"run_dir": str(run_dir), "index": str(run_dir / "docs" / "INDEX.md"),
|
|
1585
|
+
"subagent_tasks": sum(task["attempts"] for task in state["tasks"].values())}
|
|
1586
|
+
ungrounded_count = sum(bool(unit.get("ungrounded")) for unit in units.values())
|
|
1587
|
+
if ungrounded_count:
|
|
1588
|
+
summary["ungrounded_count"] = ungrounded_count
|
|
1589
|
+
summary.update(_fallback_fields(state))
|
|
1590
|
+
if reason:
|
|
1591
|
+
summary["partial_reason"], summary["partial_detail"] = reason, state.get("partial_detail", "")
|
|
1592
|
+
state["verify"] = verify_state(run_dir, state)
|
|
1593
|
+
if not state["verify"]["ok"] and state["status"] == "complete":
|
|
1594
|
+
state["status"] = "partial"
|
|
1595
|
+
summary["status"] = state["status"]
|
|
1596
|
+
summary["deterministic_checks_ok"] = state["verify"]["ok"]
|
|
1597
|
+
summary["tree"] = {"leaves": sum(1 for node in tree["nodes"] if node.get("unit_ids")),
|
|
1598
|
+
"nodes": len(tree["nodes"]), "program_made_titles": made,
|
|
1599
|
+
"naming_tasks": sum(task["kind"] in TREE_KINDS for task in state["tasks"].values()),
|
|
1600
|
+
"index": meta["index"]}
|
|
1601
|
+
summary["node_dependencies"] = meta["relationship_counts"]
|
|
1602
|
+
summary["within_containment"] = meta["within_containment_count"]
|
|
1603
|
+
summary["key_flows"] = meta["key_flows"]
|
|
1604
|
+
summary["candidate_flows"] = meta["candidate_flows"]
|
|
1605
|
+
summary["layers"] = catalog_query.layer_metrics(run_dir)
|
|
1606
|
+
summary["semantic_review"] = _semantic_review_summary(run_dir, state)
|
|
1607
|
+
summary["truth"] = ("deterministic structure/coverage checks and the separate semantic review are "
|
|
1608
|
+
"different states; neither one proves prose truth")
|
|
1609
|
+
write_json(run_dir / "summary.json", summary)
|
|
1610
|
+
log_event(run_dir, "render", f"{published_pages} pages, status {state['status']}"
|
|
1611
|
+
+ (f", pending {summary['pending']}" if summary["pending"] else "")
|
|
1612
|
+
+ f", verify {'ok' if state['verify']['ok'] else 'has failures'} -> docs/INDEX.md",
|
|
1613
|
+
pages=published_pages, status=state["status"], verify_ok=state["verify"]["ok"])
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def verify_state(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
|
|
1617
|
+
checks: list[dict[str, Any]] = []
|
|
1618
|
+
|
|
1619
|
+
def check(name: str, ok: bool, detail: Any = None) -> None:
|
|
1620
|
+
checks.append({"check": name, "ok": bool(ok), **({"detail": detail} if detail else {})})
|
|
1621
|
+
|
|
1622
|
+
unfinished = [task["id"] for task in state["tasks"].values() if task["status"] != "done"]
|
|
1623
|
+
check("all_tasks_done", not unfinished, unfinished[:20])
|
|
1624
|
+
try:
|
|
1625
|
+
_verified_context(run_dir, state)
|
|
1626
|
+
check("frozen_context_intact", True)
|
|
1627
|
+
except HostRunError as exc:
|
|
1628
|
+
check("frozen_context_intact", False, str(exc)[:300])
|
|
1629
|
+
changed = _drift(state)
|
|
1630
|
+
check("sources_unchanged", not changed, changed[:20])
|
|
1631
|
+
planned = [row["path"] for row in state["files"]]
|
|
1632
|
+
assigned = [path for module in state["modules"] for path in module["files"]]
|
|
1633
|
+
duplicates = sorted({path for path in assigned if assigned.count(path) > 1})
|
|
1634
|
+
check("each_file_in_exactly_one_module", sorted(assigned) == sorted(planned) and not duplicates,
|
|
1635
|
+
{"planned": len(planned), "assigned": len(assigned), "duplicates": duplicates[:20]})
|
|
1636
|
+
try:
|
|
1637
|
+
current = {item.path for item in batching.scan(Path(state["repo"]))}
|
|
1638
|
+
except batching.BatchingError as exc:
|
|
1639
|
+
current = set()
|
|
1640
|
+
check("rescan", False, str(exc))
|
|
1641
|
+
unplanned = sorted(current - set(planned))
|
|
1642
|
+
check("no_unplanned_source_files", not unplanned, unplanned[:20])
|
|
1643
|
+
if not (run_dir / "docs" / "INDEX.md").is_file() or not (run_dir / "catalog.json").is_file():
|
|
1644
|
+
check("semantic_rendered", False, "docs/INDEX.md or catalog.json is missing")
|
|
1645
|
+
return {"ok": False, "checks": checks}
|
|
1646
|
+
try:
|
|
1647
|
+
units, dispositions = _semantic_units(run_dir, state)
|
|
1648
|
+
tree = _semantic_tree(run_dir, state)
|
|
1649
|
+
checks += docs.verify(run_dir, state.get("inventory") or {}, units, dispositions, tree)
|
|
1650
|
+
except (symbols.SemanticError, OSError, ValueError) as exc:
|
|
1651
|
+
check("semantic_checks", False, str(exc)[:300])
|
|
1652
|
+
return {"ok": all(row["ok"] for row in checks), "checks": checks}
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def verify(run_dir: Path) -> dict[str, Any]:
|
|
1656
|
+
with locked_state(run_dir) as state:
|
|
1657
|
+
state["verify"] = verify_state(run_dir, state)
|
|
1658
|
+
write_json(state_dir(run_dir) / "verify.json", state["verify"])
|
|
1659
|
+
if state.get("rendered") and all(task["status"] == "done" for task in state["tasks"].values()):
|
|
1660
|
+
state["status"] = "complete" if state["verify"]["ok"] and not state.get("partial_reason") else "partial"
|
|
1661
|
+
summary_path = run_dir / "summary.json"
|
|
1662
|
+
if summary_path.is_file():
|
|
1663
|
+
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
1664
|
+
summary.update(status=state["status"], deterministic_checks_ok=state["verify"]["ok"])
|
|
1665
|
+
write_json(summary_path, summary)
|
|
1666
|
+
return state["verify"]
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def render(run_dir: Path) -> dict[str, Any]:
|
|
1670
|
+
"""Render now, including placeholders for anything still pending."""
|
|
1671
|
+
with locked_state(run_dir) as state:
|
|
1672
|
+
_render(run_dir, state)
|
|
1673
|
+
if state.get("render_error"):
|
|
1674
|
+
raise HostRunError(f"render failed: {state['render_error']}")
|
|
1675
|
+
return {"index": str(run_dir / "docs" / "INDEX.md"), "status": state["status"], "verify": state["verify"]}
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def mark_partial(run_dir: Path, reason: str | None, detail: str = "") -> None:
|
|
1679
|
+
"""Keep a finished run from reading "complete" when something outside the tasks says it is not.
|
|
1680
|
+
|
|
1681
|
+
Set or clear ``reason``; the summary, STATUS.md and the render status follow it."""
|
|
1682
|
+
with locked_state(run_dir) as state:
|
|
1683
|
+
if state.get("partial_reason") == reason and state.get("partial_detail", "") == detail:
|
|
1684
|
+
return
|
|
1685
|
+
if reason:
|
|
1686
|
+
state["partial_reason"], state["partial_detail"] = reason, detail
|
|
1687
|
+
log_event(run_dir, "partial", f"{reason}: {detail}", reason=reason)
|
|
1688
|
+
else:
|
|
1689
|
+
state.pop("partial_reason", None)
|
|
1690
|
+
state.pop("partial_detail", None)
|
|
1691
|
+
if state.get("rendered"):
|
|
1692
|
+
_render(run_dir, state)
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def finish(run_dir: Path) -> dict[str, Any]:
|
|
1696
|
+
"""Make sure the docs reflect the current state; return the run summary."""
|
|
1697
|
+
with locked_state(run_dir) as state:
|
|
1698
|
+
if not state.get("rendered") or state["status"] == "running":
|
|
1699
|
+
_render(run_dir, state)
|
|
1700
|
+
summary_path = run_dir / "summary.json"
|
|
1701
|
+
summary = json.loads(summary_path.read_text(encoding="utf-8")) if summary_path.is_file() else {}
|
|
1702
|
+
return {**summary, **_summary(state), "render_error": state.get("render_error")}
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _status_markdown(state: dict[str, Any]) -> str:
|
|
1706
|
+
counts = Counter(task["status"] for task in state["tasks"].values())
|
|
1707
|
+
unknown = [f"{row['task']} (attempt {row['attempt']})" for row in state.get("usage_unknown") or []]
|
|
1708
|
+
review_tasks = [task for task in state["tasks"].values() if task["kind"] == "review"]
|
|
1709
|
+
review_line = ([f"Semantic review: mode **{state['review']}**; "
|
|
1710
|
+
f"{sum(task['status'] == 'done' for task in review_tasks)}/{len(review_tasks)} "
|
|
1711
|
+
"reviewed by a separate reader of the source. This is independent of the "
|
|
1712
|
+
"deterministic structure/coverage checks and does not prove whole-map truth.", ""]
|
|
1713
|
+
if review_tasks else [])
|
|
1714
|
+
unexplained = _unexplained(state)
|
|
1715
|
+
correction_lines = [f"unexplained {len(unexplained)}", ""]
|
|
1716
|
+
correction_lines += [f"- {row['task']}: {docs.code_text(row['symbol'])}" for row in unexplained]
|
|
1717
|
+
for code in FALLBACK_CODES:
|
|
1718
|
+
if rows := state.get(code, []):
|
|
1719
|
+
correction_lines += ["", f"{code} {len(rows)}", ""]
|
|
1720
|
+
correction_lines += [f"- {docs.code_text(row['path'])}: {row['message']}" for row in rows]
|
|
1721
|
+
lines = ["# CBE run status", "",
|
|
1722
|
+
f"Run `{state['run_tag']}` · status **{state['status']}** · updated {state['updated_at']} · "
|
|
1723
|
+
f"review {state['review']} · jobs {state['jobs']} · host {state['host']}", "",
|
|
1724
|
+
*review_line,
|
|
1725
|
+
*([f"**Partial: {state['partial_reason']}** · {state.get('partial_detail', '')}", ""]
|
|
1726
|
+
if state.get("partial_reason") else []),
|
|
1727
|
+
*(["**Deterministic verification failed** · " + ", ".join(
|
|
1728
|
+
row["check"] for row in state["verify"]["checks"] if not row["ok"])
|
|
1729
|
+
+ ". Inspect `cbe host verify`; fix the cause and rerun verification.", ""]
|
|
1730
|
+
if state.get("verify") and not state["verify"]["ok"] else []),
|
|
1731
|
+
*([f"**Usage unknown** · the host reported no usage, so per-call source delivery could not "
|
|
1732
|
+
f"be verified for: {', '.join(unknown)}", ""] if unknown else []),
|
|
1733
|
+
"Counts: " + ", ".join(f"{key} {value}" for key, value in sorted(counts.items())), "",
|
|
1734
|
+
*correction_lines, "",
|
|
1735
|
+
"| Task | Module | State | Attempts | Seconds | Output | Note |", "|---|---|---|---:|---:|---|---|"]
|
|
1736
|
+
for task in sorted(state["tasks"].values(), key=lambda row: (ORDER.get(row["kind"], 0), row["module"] or 0)):
|
|
1737
|
+
output = task["output"] or ""
|
|
1738
|
+
task_unexplained = sum(row["task"] == task["id"] for row in unexplained)
|
|
1739
|
+
shape_flags = [flag for flag in task["flags"] if not flag.startswith("unexplained: ")]
|
|
1740
|
+
note = (task["errors"][-1][:120] if task["status"] in {"failed", "pending"} and task["errors"]
|
|
1741
|
+
else f"normalized ({len(shape_flags)})" if shape_flags else "")
|
|
1742
|
+
if task_unexplained:
|
|
1743
|
+
note += ("; " if note else "") + f"unexplained {task_unexplained}"
|
|
1744
|
+
lines.append(f"| {task['id']} | {_title(state, task) if task['module'] is not None else ''} | "
|
|
1745
|
+
f"{task['status']} | {task['attempts']} | {task['seconds'] if task['seconds'] is not None else ''} | "
|
|
1746
|
+
f"{output} | {note.replace('|', '/')} |")
|
|
1747
|
+
return "\n".join(lines) + "\n"
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
# ---------------------------------------------------------------- usage and cost
|
|
1751
|
+
|
|
1752
|
+
|