claude-dev-env 2.7.1 → 2.9.0
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.
- package/CLAUDE.md +7 -1
- package/_shared/CLAUDE.md +1 -0
- package/_shared/advisor/advisor-protocol.md +19 -9
- package/_shared/pr-loop/audit-contract.md +4 -4
- package/_shared/pr-loop/precatch-rubric.md +2 -2
- package/_shared/process-tree/CLAUDE.md +41 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
- package/_shared/process-tree/scripts/pyproject.toml +16 -0
- package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
- package/agents/clean-coder.md +9 -19
- package/agents/code-quality-agent.md +6 -5
- package/agents/deep-research.md +7 -24
- package/agents/docs-agent.md +1 -27
- package/agents/issue-tracker.md +1 -7
- package/agents/skill-writer-agent.md +1 -2
- package/agents/test_agent_frontmatter.py +335 -12
- package/docs/CODE_RULES.md +4 -2
- package/docs/references/CLAUDE.md +2 -2
- package/docs/references/advisor-tool.md +44 -6
- package/docs/references/team-advisor-skill.md +14 -8
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/fable_spawn_gate.py +187 -0
- package/hooks/blocking/piped_pytest_blocker.py +1223 -0
- package/hooks/blocking/plain_language_blocker.py +287 -15
- package/hooks/blocking/test_fable_spawn_gate.py +374 -0
- package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
- package/hooks/blocking/test_plain_language_blocker.py +277 -2
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
- package/hooks/git-hooks/CLAUDE.md +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
- package/hooks/git-hooks/pre_push.py +343 -54
- package/hooks/git-hooks/test_pre_push.py +852 -6
- package/hooks/hooks.json +9 -19
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
- package/hooks/hooks_constants/shell_command_segments.py +1 -1
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
- package/output-styles/CLAUDE.md +17 -0
- package/output-styles/caveman-agent.md +37 -0
- package/package.json +2 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/ask-user-question-required.md +26 -0
- package/rules/claims-as-quotes.md +65 -0
- package/rules/code-standards.md +33 -7
- package/rules/eli11-replies.md +1 -1
- package/scripts/CLAUDE.md +3 -3
- package/scripts/_code_review_test_support.py +6 -0
- package/scripts/check.ps1 +18 -5
- package/scripts/claude_chain_runner.py +203 -31
- package/scripts/codec_forwarding_test_support.py +2 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
- package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +132 -24
- package/scripts/grok_headless_runner.py +16 -83
- package/scripts/grok_patch_artifacts.py +123 -0
- package/scripts/grok_run_ledger.py +318 -0
- package/scripts/spawn_grok_batch.py +591 -10
- package/scripts/test_claude_chain_runner.py +358 -0
- package/scripts/test_grok_headless_runner.py +18 -63
- package/scripts/test_grok_patch_artifacts.py +82 -0
- package/scripts/test_grok_run_ledger.py +116 -0
- package/scripts/test_invoke_code_review.py +298 -0
- package/scripts/test_resolve_worker_spawn.py +6 -0
- package/scripts/test_spawn_grok_batch.py +396 -0
- package/scripts/tests/CLAUDE.md +1 -0
- package/scripts/tests/test_grok_worker_constants.py +59 -0
- package/skills/CLAUDE.md +4 -2
- package/skills/_shared/CLAUDE.md +37 -4
- package/skills/_shared/advisor/CLAUDE.md +9 -0
- package/skills/_shared/advisor/advisor-protocol.md +5 -0
- package/skills/_shared/advisor/scripts/README.md +9 -0
- package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -1
- package/skills/_shared/pr-loop/audit-contract.md +5 -0
- package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
- package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
- package/skills/_shared/pr-loop/fix-protocol.md +5 -0
- package/skills/_shared/pr-loop/gh-payloads.md +5 -0
- package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
- package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +8 -1
- package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
- package/skills/_shared/pr-loop/state-schema.md +5 -0
- package/skills/_shared/pr-loop/worker-spawn.md +5 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
- package/skills/autoconverge/workflow/converge.mjs +66 -33
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
- package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
- package/skills/codex-review/scripts/run_codex_review.py +16 -64
- package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
- package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
- package/skills/e-code-review/SKILL.md +15 -9
- package/skills/e-code-review/reference/fix.md +29 -7
- package/skills/e-code-review/reference/loop.md +230 -14
- package/skills/e-code-review/reference/low.md +33 -15
- package/skills/e-code-review/reference/medium.md +55 -21
- package/skills/e-code-review/reference/runner-selection.md +40 -0
- package/skills/e-code-review/reference/xhigh.md +30 -12
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
- package/skills/e-code-review/scripts/grok_code_review.py +221 -0
- package/skills/e-code-review/scripts/test_grok_code_review.py +212 -0
- package/skills/fresh-branch/CLAUDE.md +5 -5
- package/skills/fresh-branch/SKILL.md +14 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
- package/skills/grok-spawn/SKILL.md +10 -0
- package/skills/orchestrator/SKILL.md +9 -1
- package/skills/orchestrator-refresh/SKILL.md +5 -1
- package/skills/task-build/reference/tool-routing.md +3 -0
- package/skills/team-advisor/SKILL.md +23 -41
- package/system-prompts/software-engineer.xml +6 -3
- package/skills/test_markdown_link_integrity.py +0 -107
|
@@ -25,14 +25,24 @@ Import ``run_headless_worker`` for the outcome object::
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
-
import os
|
|
29
|
-
import signal
|
|
30
28
|
import subprocess
|
|
29
|
+
import sys
|
|
31
30
|
import uuid
|
|
32
31
|
from dataclasses import dataclass
|
|
33
32
|
from pathlib import Path
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
_shared_process_tree_scripts_directory = (
|
|
35
|
+
Path(__file__).resolve().parents[1] / "_shared" / "process-tree" / "scripts"
|
|
36
|
+
)
|
|
37
|
+
if str(_shared_process_tree_scripts_directory) not in sys.path:
|
|
38
|
+
sys.path.insert(0, str(_shared_process_tree_scripts_directory))
|
|
39
|
+
|
|
40
|
+
from process_tree_kill import ( # noqa: E402
|
|
41
|
+
should_start_new_session,
|
|
42
|
+
terminate_process_tree,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
36
46
|
AGENT_FLAG,
|
|
37
47
|
ALL_AUTH_FAILURE_SIGNATURES,
|
|
38
48
|
ALL_USAGE_LIMIT_SIGNATURES,
|
|
@@ -65,21 +75,14 @@ from dev_env_scripts_constants.grok_worker_constants import (
|
|
|
65
75
|
OUTPUT_FORMAT_FLAG,
|
|
66
76
|
OUTPUT_FORMAT_JSON,
|
|
67
77
|
PROCESS_TREE_KILL_ATTEMPT_LIMIT,
|
|
68
|
-
PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
69
78
|
PROMPT_FILE_FLAG,
|
|
70
79
|
TIMEOUT_RETURN_CODE,
|
|
71
80
|
UTF8_DECODE_ERRORS,
|
|
72
81
|
UTF8_ENCODING,
|
|
73
|
-
WINDOWS_OS_NAME,
|
|
74
|
-
WINDOWS_TASKKILL_COMMAND,
|
|
75
|
-
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
76
|
-
WINDOWS_TASKKILL_PID_FLAG,
|
|
77
|
-
WINDOWS_TASKKILL_TREE_FLAG,
|
|
78
82
|
WORKER_SPEC_TIMEOUT_KEY,
|
|
79
83
|
)
|
|
80
84
|
|
|
81
85
|
runner_popen = subprocess.Popen
|
|
82
|
-
runner_subprocess_run = subprocess.run
|
|
83
86
|
|
|
84
87
|
|
|
85
88
|
class WorkerTimeoutOutOfBoundsError(ValueError):
|
|
@@ -204,76 +207,6 @@ def _resolve_returncode(process: subprocess.Popen[str]) -> int:
|
|
|
204
207
|
return TIMEOUT_RETURN_CODE
|
|
205
208
|
|
|
206
209
|
|
|
207
|
-
def _kill_windows_process_tree(process_identifier: int) -> None:
|
|
208
|
-
"""End a Windows process and every descendant it started, by process id.
|
|
209
|
-
|
|
210
|
-
Swallows taskkill failures so the caller still falls back to
|
|
211
|
-
``Popen.kill()`` and a timed drain.
|
|
212
|
-
"""
|
|
213
|
-
try:
|
|
214
|
-
runner_subprocess_run(
|
|
215
|
-
[
|
|
216
|
-
WINDOWS_TASKKILL_COMMAND,
|
|
217
|
-
WINDOWS_TASKKILL_TREE_FLAG,
|
|
218
|
-
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
219
|
-
WINDOWS_TASKKILL_PID_FLAG,
|
|
220
|
-
str(process_identifier),
|
|
221
|
-
],
|
|
222
|
-
stdout=subprocess.DEVNULL,
|
|
223
|
-
stderr=subprocess.DEVNULL,
|
|
224
|
-
check=False,
|
|
225
|
-
timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
226
|
-
)
|
|
227
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
228
|
-
return
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _kill_posix_process_group(process_identifier: int) -> None:
|
|
232
|
-
"""End a POSIX process group so no grandchild keeps the capture pipe open.
|
|
233
|
-
|
|
234
|
-
Reached only through the caller's ``os.name`` branch, so the process-group
|
|
235
|
-
calls run on the platforms that define them.
|
|
236
|
-
|
|
237
|
-
A process id that is already reaped raises ``OSError``; this swallows it and
|
|
238
|
-
returns, so the caller falls back to ``Popen.kill()``.
|
|
239
|
-
"""
|
|
240
|
-
try:
|
|
241
|
-
process_group_identifier = os.getpgid(process_identifier) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
|
|
242
|
-
os.killpg(process_group_identifier, signal.SIGKILL) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
|
|
243
|
-
except OSError:
|
|
244
|
-
return
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def _kill_process_tree_by_identifier(process_identifier: int) -> None:
|
|
248
|
-
"""Issue the platform's tree kill for one process id, with no liveness check."""
|
|
249
|
-
if os.name == WINDOWS_OS_NAME:
|
|
250
|
-
_kill_windows_process_tree(process_identifier)
|
|
251
|
-
return
|
|
252
|
-
_kill_posix_process_group(process_identifier)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
|
|
256
|
-
"""End the worker process and every descendant it spawned.
|
|
257
|
-
|
|
258
|
-
::
|
|
259
|
-
|
|
260
|
-
tree kill (taskkill /T or killpg) ok: grandchildren die, pipes close
|
|
261
|
-
Popen.kill() alone flag: grandchildren outlive the worker
|
|
262
|
-
|
|
263
|
-
Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
|
|
264
|
-
so the caller never waits on a live process.
|
|
265
|
-
"""
|
|
266
|
-
if process.poll() is not None:
|
|
267
|
-
return
|
|
268
|
-
_kill_process_tree_by_identifier(process.pid)
|
|
269
|
-
if process.poll() is not None:
|
|
270
|
-
return
|
|
271
|
-
try:
|
|
272
|
-
process.kill()
|
|
273
|
-
except ProcessLookupError:
|
|
274
|
-
return
|
|
275
|
-
|
|
276
|
-
|
|
277
210
|
def require_timeout_within_bounds(timeout_seconds: int | None) -> None:
|
|
278
211
|
"""Refuse a timeout that is missing, below the floor, or above the ceiling.
|
|
279
212
|
|
|
@@ -332,7 +265,7 @@ def _kill_and_drain_within_attempt_limit(
|
|
|
332
265
|
|
|
333
266
|
A tree kill that returns without taking leaves the drain waiting on a live
|
|
334
267
|
pipe, so a timed-out drain is followed by another kill-and-drain round.
|
|
335
|
-
``
|
|
268
|
+
``terminate_process_tree`` re-issues the kill only while the worker
|
|
336
269
|
process is still alive; once it has exited, the next round is a second
|
|
337
270
|
drain window for the descendants still holding the pipe open.
|
|
338
271
|
|
|
@@ -344,7 +277,7 @@ def _kill_and_drain_within_attempt_limit(
|
|
|
344
277
|
"""
|
|
345
278
|
attempts_made = 0
|
|
346
279
|
while attempts_made < PROCESS_TREE_KILL_ATTEMPT_LIMIT:
|
|
347
|
-
|
|
280
|
+
terminate_process_tree(process)
|
|
348
281
|
all_captured_streams = _drain_after_kill(process)
|
|
349
282
|
if all_captured_streams is not None:
|
|
350
283
|
return all_captured_streams
|
|
@@ -424,7 +357,7 @@ def _invoke_process(
|
|
|
424
357
|
text=True,
|
|
425
358
|
encoding=UTF8_ENCODING,
|
|
426
359
|
errors=UTF8_DECODE_ERRORS,
|
|
427
|
-
start_new_session=
|
|
360
|
+
start_new_session=should_start_new_session(),
|
|
428
361
|
)
|
|
429
362
|
except FileNotFoundError:
|
|
430
363
|
return _missing_binary_outcome()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract patch artifacts and SHA-256 manifests from isolated worktrees.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
manifest = write_patch_manifest(
|
|
7
|
+
run_state_directory=run_dir,
|
|
8
|
+
task_id="O-04",
|
|
9
|
+
base_sha="abc",
|
|
10
|
+
worktree_path=worktree,
|
|
11
|
+
worker_report_text="ok",
|
|
12
|
+
)
|
|
13
|
+
ok: manifest carries content hash and changed paths
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from dev_env_scripts_constants.grok_run_ledger_constants import (
|
|
24
|
+
JSON_INDENT,
|
|
25
|
+
LEDGER_SCHEMA_VERSION,
|
|
26
|
+
PATCH_MANIFEST_FILENAME,
|
|
27
|
+
UTF8_ENCODING,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compute_sha256_hex(content: bytes) -> str:
|
|
32
|
+
"""Return the hex SHA-256 digest of raw bytes.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
content: Bytes to hash.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Lowercase hex digest string.
|
|
39
|
+
"""
|
|
40
|
+
return hashlib.sha256(content).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_worktree_diff(
|
|
44
|
+
*,
|
|
45
|
+
worktree_path: Path,
|
|
46
|
+
base_sha: str,
|
|
47
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
48
|
+
"""Return unified diff text and changed paths against base_sha.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
worktree_path: Isolated worker worktree.
|
|
52
|
+
base_sha: Base commit SHA the worker started from.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
``(diff_text, changed_paths)``.
|
|
56
|
+
"""
|
|
57
|
+
diff_text = subprocess.check_output(
|
|
58
|
+
["git", "-C", str(worktree_path), "diff", base_sha],
|
|
59
|
+
text=True,
|
|
60
|
+
encoding=UTF8_ENCODING,
|
|
61
|
+
)
|
|
62
|
+
changed_paths_listing = subprocess.check_output(
|
|
63
|
+
["git", "-C", str(worktree_path), "diff", "--name-only", base_sha],
|
|
64
|
+
text=True,
|
|
65
|
+
encoding=UTF8_ENCODING,
|
|
66
|
+
)
|
|
67
|
+
changed_paths = tuple(
|
|
68
|
+
each_line.strip()
|
|
69
|
+
for each_line in changed_paths_listing.splitlines()
|
|
70
|
+
if each_line.strip()
|
|
71
|
+
)
|
|
72
|
+
return diff_text, changed_paths
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write_patch_manifest(
|
|
76
|
+
*,
|
|
77
|
+
run_state_directory: Path,
|
|
78
|
+
task_id: str,
|
|
79
|
+
base_sha: str,
|
|
80
|
+
worktree_path: Path,
|
|
81
|
+
worker_report_text: str,
|
|
82
|
+
patch_filename: str | None = None,
|
|
83
|
+
) -> dict[str, object]:
|
|
84
|
+
"""Write a patch file and JSON manifest binding hashes and paths.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
run_state_directory: Directory that holds ledger and patch artifacts.
|
|
88
|
+
task_id: Task the patch belongs to.
|
|
89
|
+
base_sha: Base commit SHA.
|
|
90
|
+
worktree_path: Worker worktree to diff.
|
|
91
|
+
worker_report_text: Worker report body bound into the manifest.
|
|
92
|
+
patch_filename: Optional override for the ``.patch`` filename.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The manifest document written to disk.
|
|
96
|
+
"""
|
|
97
|
+
run_state_directory = Path(run_state_directory)
|
|
98
|
+
run_state_directory.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
diff_text, changed_paths = extract_worktree_diff(
|
|
100
|
+
worktree_path=Path(worktree_path),
|
|
101
|
+
base_sha=base_sha,
|
|
102
|
+
)
|
|
103
|
+
patch_name = patch_filename or f"{task_id}.patch"
|
|
104
|
+
patch_path = run_state_directory / patch_name
|
|
105
|
+
patch_bytes = diff_text.encode(UTF8_ENCODING)
|
|
106
|
+
patch_path.write_bytes(patch_bytes)
|
|
107
|
+
content_hash = compute_sha256_hex(patch_bytes)
|
|
108
|
+
report_hash = compute_sha256_hex(worker_report_text.encode(UTF8_ENCODING))
|
|
109
|
+
manifest = {
|
|
110
|
+
"schema_version": LEDGER_SCHEMA_VERSION,
|
|
111
|
+
"task_id": task_id,
|
|
112
|
+
"base_sha": base_sha,
|
|
113
|
+
"changed_paths": list(changed_paths),
|
|
114
|
+
"patch_path": str(patch_path),
|
|
115
|
+
"content_sha256": content_hash,
|
|
116
|
+
"worker_report_sha256": report_hash,
|
|
117
|
+
}
|
|
118
|
+
manifest_path = run_state_directory / PATCH_MANIFEST_FILENAME
|
|
119
|
+
manifest_path.write_text(
|
|
120
|
+
json.dumps(manifest, indent=JSON_INDENT) + "\n",
|
|
121
|
+
encoding=UTF8_ENCODING,
|
|
122
|
+
)
|
|
123
|
+
return manifest
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Host-neutral file-backed ledger for Grok orchestration task state.
|
|
3
|
+
|
|
4
|
+
Records every delegated unit before dispatch, enforces one live owner and one
|
|
5
|
+
unique advisor session per in-progress task, blocks on unfinished dependencies,
|
|
6
|
+
and reopens tasks when the base snapshot drifts.
|
|
7
|
+
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
ledger = GrokRunLedger(run_state_directory)
|
|
11
|
+
ledger.register_task(task_id="O-04", dependencies=())
|
|
12
|
+
ledger.mark_in_progress(
|
|
13
|
+
task_id="O-04",
|
|
14
|
+
owner_id="worker-1",
|
|
15
|
+
advisor_session_id="sess-1",
|
|
16
|
+
base_sha="abc",
|
|
17
|
+
)
|
|
18
|
+
ok: task status becomes in_progress with that owner and session
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import tempfile
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from dev_env_scripts_constants.grok_run_ledger_constants import (
|
|
30
|
+
ALL_LEGAL_TASK_STATUSES,
|
|
31
|
+
JSON_INDENT,
|
|
32
|
+
LEDGER_FILENAME,
|
|
33
|
+
LEDGER_SCHEMA_VERSION,
|
|
34
|
+
TASK_STATUS_ADVISOR_BLOCKED,
|
|
35
|
+
TASK_STATUS_COMPLETED,
|
|
36
|
+
TASK_STATUS_IN_PROGRESS,
|
|
37
|
+
TASK_STATUS_PENDING,
|
|
38
|
+
TASK_STATUS_PENDING_REVIEW,
|
|
39
|
+
TEMPORARY_LEDGER_PREFIX,
|
|
40
|
+
TEMPORARY_LEDGER_SUFFIX,
|
|
41
|
+
UTF8_ENCODING,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LedgerTaskRecord:
|
|
47
|
+
"""One delegated unit tracked in the run ledger."""
|
|
48
|
+
|
|
49
|
+
task_id: str
|
|
50
|
+
status: str = TASK_STATUS_PENDING
|
|
51
|
+
dependencies: tuple[str, ...] = ()
|
|
52
|
+
owner_id: str | None = None
|
|
53
|
+
advisor_session_id: str | None = None
|
|
54
|
+
advisor_verdict: str | None = None
|
|
55
|
+
base_sha: str | None = None
|
|
56
|
+
reviewed_head: str | None = None
|
|
57
|
+
changed_paths: tuple[str, ...] = ()
|
|
58
|
+
acceptance_mapping: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
test_evidence: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GrokRunLedger:
|
|
63
|
+
"""Atomic file-backed ledger: register_task, get_task, can_dispatch, mark_in_progress, mark_completed, mark_advisor_blocked, invalidate_on_snapshot_drift, all_tasks."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, run_state_directory: Path) -> None:
|
|
66
|
+
self.run_state_directory = Path(run_state_directory)
|
|
67
|
+
self.ledger_path = self.run_state_directory / LEDGER_FILENAME
|
|
68
|
+
self._task_by_id: dict[str, LedgerTaskRecord] = {}
|
|
69
|
+
self.run_state_directory.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
if self.ledger_path.is_file():
|
|
71
|
+
self._load()
|
|
72
|
+
|
|
73
|
+
def _load(self) -> None:
|
|
74
|
+
payload = json.loads(self.ledger_path.read_text(encoding=UTF8_ENCODING))
|
|
75
|
+
for each_task in payload.get("tasks", []):
|
|
76
|
+
record = LedgerTaskRecord(
|
|
77
|
+
task_id=each_task["task_id"],
|
|
78
|
+
status=each_task["status"],
|
|
79
|
+
dependencies=tuple(each_task.get("dependencies") or ()),
|
|
80
|
+
owner_id=each_task.get("owner_id"),
|
|
81
|
+
advisor_session_id=each_task.get("advisor_session_id"),
|
|
82
|
+
advisor_verdict=each_task.get("advisor_verdict"),
|
|
83
|
+
base_sha=each_task.get("base_sha"),
|
|
84
|
+
reviewed_head=each_task.get("reviewed_head"),
|
|
85
|
+
changed_paths=tuple(each_task.get("changed_paths") or ()),
|
|
86
|
+
acceptance_mapping=dict(each_task.get("acceptance_mapping") or {}),
|
|
87
|
+
test_evidence=list(each_task.get("test_evidence") or []),
|
|
88
|
+
)
|
|
89
|
+
self._task_by_id[record.task_id] = record
|
|
90
|
+
|
|
91
|
+
def _atomic_write(self) -> None:
|
|
92
|
+
document = {
|
|
93
|
+
"schema_version": LEDGER_SCHEMA_VERSION,
|
|
94
|
+
"tasks": [
|
|
95
|
+
{
|
|
96
|
+
**asdict(each_record),
|
|
97
|
+
"dependencies": list(each_record.dependencies),
|
|
98
|
+
"changed_paths": list(each_record.changed_paths),
|
|
99
|
+
}
|
|
100
|
+
for each_record in sorted(
|
|
101
|
+
self._task_by_id.values(), key=lambda item: item.task_id
|
|
102
|
+
)
|
|
103
|
+
],
|
|
104
|
+
}
|
|
105
|
+
encoded = json.dumps(document, indent=JSON_INDENT) + "\n"
|
|
106
|
+
file_descriptor, temporary_path = tempfile.mkstemp(
|
|
107
|
+
dir=self.run_state_directory,
|
|
108
|
+
prefix=TEMPORARY_LEDGER_PREFIX,
|
|
109
|
+
suffix=TEMPORARY_LEDGER_SUFFIX,
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
with os.fdopen(file_descriptor, "w", encoding=UTF8_ENCODING) as handle:
|
|
113
|
+
handle.write(encoded)
|
|
114
|
+
handle.flush()
|
|
115
|
+
os.fsync(handle.fileno())
|
|
116
|
+
os.replace(temporary_path, self.ledger_path)
|
|
117
|
+
except (OSError, TypeError, ValueError):
|
|
118
|
+
if os.path.exists(temporary_path):
|
|
119
|
+
os.unlink(temporary_path)
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
def register_task(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
task_id: str,
|
|
126
|
+
all_dependencies: tuple[str, ...] = (),
|
|
127
|
+
) -> LedgerTaskRecord:
|
|
128
|
+
"""Record a delegated unit before dispatch.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
task_id: Stable task identifier.
|
|
132
|
+
all_dependencies: Task ids that must complete before dispatch.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The registered pending task record.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ValueError: When the task id already exists.
|
|
139
|
+
"""
|
|
140
|
+
if task_id in self._task_by_id:
|
|
141
|
+
raise ValueError(f"task already registered: {task_id}")
|
|
142
|
+
record = LedgerTaskRecord(
|
|
143
|
+
task_id=task_id,
|
|
144
|
+
status=TASK_STATUS_PENDING,
|
|
145
|
+
dependencies=all_dependencies,
|
|
146
|
+
)
|
|
147
|
+
self._task_by_id[task_id] = record
|
|
148
|
+
self._atomic_write()
|
|
149
|
+
return record
|
|
150
|
+
|
|
151
|
+
def get_task(self, task_id: str) -> LedgerTaskRecord:
|
|
152
|
+
"""Return one task record.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
task_id: Stable task identifier.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The stored task record.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
KeyError: When the task id is unknown.
|
|
162
|
+
"""
|
|
163
|
+
return self._task_by_id[task_id]
|
|
164
|
+
|
|
165
|
+
def can_dispatch(self, task_id: str) -> bool:
|
|
166
|
+
"""Return whether every dependency has completed successfully.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
task_id: Task to evaluate.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
True when every dependency is ``completed``.
|
|
173
|
+
"""
|
|
174
|
+
record = self.get_task(task_id)
|
|
175
|
+
for each_dependency in record.dependencies:
|
|
176
|
+
dependency_record = self._task_by_id.get(each_dependency)
|
|
177
|
+
if dependency_record is None:
|
|
178
|
+
return False
|
|
179
|
+
if dependency_record.status != TASK_STATUS_COMPLETED:
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
def mark_in_progress(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
task_id: str,
|
|
187
|
+
owner_id: str,
|
|
188
|
+
advisor_session_id: str,
|
|
189
|
+
base_sha: str,
|
|
190
|
+
) -> LedgerTaskRecord:
|
|
191
|
+
"""Assign one owner and unique advisor session and start the task.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
task_id: Task to start.
|
|
195
|
+
owner_id: Live owner identity.
|
|
196
|
+
advisor_session_id: Unique advisor session for this worker.
|
|
197
|
+
base_sha: Snapshot SHA at dispatch.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The updated task record.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
ValueError: When dispatch is blocked, another owner is live, the
|
|
204
|
+
advisor session is reused, or status is illegal.
|
|
205
|
+
"""
|
|
206
|
+
if not self.can_dispatch(task_id):
|
|
207
|
+
raise ValueError(f"dependencies incomplete for {task_id}")
|
|
208
|
+
for each_record in self._task_by_id.values():
|
|
209
|
+
if (
|
|
210
|
+
each_record.status == TASK_STATUS_IN_PROGRESS
|
|
211
|
+
and each_record.owner_id == owner_id
|
|
212
|
+
and each_record.task_id != task_id
|
|
213
|
+
):
|
|
214
|
+
raise ValueError(f"owner already live on {each_record.task_id}")
|
|
215
|
+
if (
|
|
216
|
+
each_record.advisor_session_id == advisor_session_id
|
|
217
|
+
and each_record.task_id != task_id
|
|
218
|
+
):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"advisor session already bound to {each_record.task_id}"
|
|
221
|
+
)
|
|
222
|
+
record = self.get_task(task_id)
|
|
223
|
+
if record.status not in {TASK_STATUS_PENDING, TASK_STATUS_PENDING_REVIEW}:
|
|
224
|
+
raise ValueError(f"cannot start task from status {record.status}")
|
|
225
|
+
record.status = TASK_STATUS_IN_PROGRESS
|
|
226
|
+
record.owner_id = owner_id
|
|
227
|
+
record.advisor_session_id = advisor_session_id
|
|
228
|
+
record.base_sha = base_sha
|
|
229
|
+
self._atomic_write()
|
|
230
|
+
return record
|
|
231
|
+
|
|
232
|
+
def mark_completed(
|
|
233
|
+
self,
|
|
234
|
+
*,
|
|
235
|
+
task_id: str,
|
|
236
|
+
reviewed_head: str,
|
|
237
|
+
all_changed_paths: tuple[str, ...],
|
|
238
|
+
advisor_verdict: str,
|
|
239
|
+
all_acceptance_mapping: dict[str, str],
|
|
240
|
+
all_test_evidence: list[str],
|
|
241
|
+
) -> LedgerTaskRecord:
|
|
242
|
+
"""Record successful terminal state for an in-progress task.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
task_id: Task to complete.
|
|
246
|
+
reviewed_head: Final reviewed commit SHA.
|
|
247
|
+
all_changed_paths: Paths changed by the worker.
|
|
248
|
+
advisor_verdict: Opening advisor signal (for example ENDORSE).
|
|
249
|
+
all_acceptance_mapping: Acceptance criterion to evidence map.
|
|
250
|
+
all_test_evidence: Commands or artifacts proving tests.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The completed task record.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ValueError: When the task is not in progress.
|
|
257
|
+
"""
|
|
258
|
+
record = self.get_task(task_id)
|
|
259
|
+
if record.status != TASK_STATUS_IN_PROGRESS:
|
|
260
|
+
raise ValueError(f"cannot complete task from status {record.status}")
|
|
261
|
+
record.status = TASK_STATUS_COMPLETED
|
|
262
|
+
record.reviewed_head = reviewed_head
|
|
263
|
+
record.changed_paths = all_changed_paths
|
|
264
|
+
record.advisor_verdict = advisor_verdict
|
|
265
|
+
record.acceptance_mapping = dict(all_acceptance_mapping)
|
|
266
|
+
record.test_evidence = list(all_test_evidence)
|
|
267
|
+
self._atomic_write()
|
|
268
|
+
return record
|
|
269
|
+
|
|
270
|
+
def mark_advisor_blocked(self, *, task_id: str, reason: str) -> LedgerTaskRecord:
|
|
271
|
+
"""Stop a task because the advisor path failed closed.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
task_id: Task to block.
|
|
275
|
+
reason: Short failure reason stored in test evidence.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
The blocked task record.
|
|
279
|
+
"""
|
|
280
|
+
record = self.get_task(task_id)
|
|
281
|
+
record.status = TASK_STATUS_ADVISOR_BLOCKED
|
|
282
|
+
record.test_evidence = [reason]
|
|
283
|
+
self._atomic_write()
|
|
284
|
+
return record
|
|
285
|
+
|
|
286
|
+
def invalidate_on_snapshot_drift(
|
|
287
|
+
self,
|
|
288
|
+
*,
|
|
289
|
+
task_id: str,
|
|
290
|
+
current_sha: str,
|
|
291
|
+
) -> LedgerTaskRecord:
|
|
292
|
+
"""Move a task back to pending review when the base snapshot drifts.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
task_id: Task to invalidate.
|
|
296
|
+
current_sha: Live SHA compared to the recorded base.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The updated task record (unchanged when SHAs match).
|
|
300
|
+
"""
|
|
301
|
+
record = self.get_task(task_id)
|
|
302
|
+
if record.base_sha is None or record.base_sha == current_sha:
|
|
303
|
+
return record
|
|
304
|
+
record.status = TASK_STATUS_PENDING_REVIEW
|
|
305
|
+
record.owner_id = None
|
|
306
|
+
self._atomic_write()
|
|
307
|
+
return record
|
|
308
|
+
|
|
309
|
+
def all_tasks(self) -> tuple[LedgerTaskRecord, ...]:
|
|
310
|
+
"""Return every task record sorted by task id."""
|
|
311
|
+
return tuple(
|
|
312
|
+
sorted(self._task_by_id.values(), key=lambda item: item.task_id)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def is_legal_status(status: str) -> bool:
|
|
317
|
+
"""Return whether a status token is in the legal set."""
|
|
318
|
+
return status in ALL_LEGAL_TASK_STATUSES
|