engineering-process 2.3.0__tar.gz → 2.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {engineering_process-2.3.0/engineering_process.egg-info → engineering_process-2.3.1}/PKG-INFO +32 -6
- {engineering_process-2.3.0 → engineering_process-2.3.1}/README.md +31 -5
- engineering_process-2.3.1/RELEASE_NOTES.md +18 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/__init__.py +1 -1
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/cli.py +5 -1
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/contracts.py +14 -3
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/lifecycle.py +73 -3
- engineering_process-2.3.1/engineering_process/review_contexts.py +291 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1/engineering_process.egg-info}/PKG-INFO +32 -6
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/SOURCES.txt +3 -1
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-review/SKILL.md +31 -2
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/deliver-change/SKILL.md +3 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/pyproject.toml +1 -1
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/run.schema.json +28 -1
- {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/adopt-process.py +10 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_architecture.py +1 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_automation.py +5 -15
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_cli.py +1 -0
- engineering_process-2.3.1/tests/test_pypi_cache.py +184 -0
- engineering_process-2.3.1/tests/test_review_contexts.py +507 -0
- engineering_process-2.3.0/RELEASE_NOTES.md +0 -23
- engineering_process-2.3.0/tests/test_pypi_cache_horizon.py +0 -81
- {engineering_process-2.3.0 → engineering_process-2.3.1}/LICENSE +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/MANIFEST.in +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/__main__.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_supervisor_contract.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_supervisor_posix.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_supervisor_windows.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_windows_job.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/adoption.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/artifact_standards.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/automation_name.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/commands.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/distribution.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/helper_launch.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/issue.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/pr_description.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/production_engineering.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/project.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/publication_compat.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/release.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/release_notes.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/repository.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/requirements-dev.txt +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/requirements-runtime.txt +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/skills.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/source_publication.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/supervision.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/dependency_links.txt +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/entry_points.txt +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/requires.txt +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/top_level.txt +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process-graph.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-complete/SKILL.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-implement/SKILL.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-plan/SKILL.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-start/SKILL.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-verify/SKILL.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/process-improve/SKILL.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/production-engineering/SKILL.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/production-engineering/invariants.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/automation-name.v1.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/issue.v1.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/pull-request.v1.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/release-notes.v1.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/artifact-selection.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/artifact-standard.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/automation-name-data.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/change.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/issue-data.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/plan.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/pr-description-data.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/process-graph.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/process-lock.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/production-engineering.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/project-legacy.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/project.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/receipt.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/release-change.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/release-notes-data.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/release.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/renovate-preset.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/review.schema.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/setup.cfg +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/AGENTS.process.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/adopt-process-windows-job.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/renovate.json +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_adoption.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_artifact_standards.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_commands.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_contracts.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_distribution.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_lifecycle.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_production_engineering.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_publication_compat.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_publication_workflow.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_release.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_repository.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_skills.py +0 -0
- {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_supervisor_posix.py +0 -0
{engineering_process-2.3.0/engineering_process.egg-info → engineering_process-2.3.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: engineering-process
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: A small agent-neutral engineering lifecycle with managed adoption
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/phuongnse/engineering-process
|
|
@@ -64,19 +64,35 @@ under .process/receipts. Both paths are ignored by Git. Verification evidence is
|
|
|
64
64
|
to HEAD plus a fingerprint of tracked and non-ignored untracked files. Any relevant
|
|
65
65
|
mutation invalidates it.
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
Neither the reviewer actor nor reviewer context may have implemented the current
|
|
68
|
+
cycle. An agent reviewer context also cannot be recorded in another accepted change
|
|
69
|
+
in this repository or its registered worktrees. There is no attestation hierarchy,
|
|
69
70
|
recommendation chain, authority-transition protocol, remote-evidence federation, or
|
|
70
71
|
second handwritten validator.
|
|
71
72
|
|
|
72
|
-
The coordinator hands
|
|
73
|
-
|
|
74
|
-
reviewer continues corrections. The reviewer inspects the change and authors its
|
|
73
|
+
The coordinator hands each new change to a newly spawned reviewer, with the accepted
|
|
74
|
+
source artifacts and no inherited implementation or other-change review conversation.
|
|
75
|
+
The same reviewer continues only that change's corrections. The reviewer inspects the change and authors its
|
|
75
76
|
own report. The runner's existing task/session interaction and returned result make
|
|
76
77
|
that work inspectable. Sharing a provider or account is allowed; this is a
|
|
77
78
|
workflow for independent judgment, not authenticated identity or merge enforcement.
|
|
78
79
|
See [change-review](process_assets/skills/change-review/SKILL.md) for the handoff.
|
|
79
80
|
|
|
81
|
+
Start, submission and finish check schema-valid canonical run history with bounded
|
|
82
|
+
worktree/file reads. A short OS lock in common Git metadata serializes canonical
|
|
83
|
+
history writes and review checks; profiles run outside it and reload current state
|
|
84
|
+
before recording results. The lock stores no identity registry. The check rejects known context reuse,
|
|
85
|
+
but cannot establish native conversation freshness or inspect removed history,
|
|
86
|
+
other clones or other repositories. Keep native creation, non-inherited dispatch
|
|
87
|
+
and effective settings evidence in the existing handoff.
|
|
88
|
+
|
|
89
|
+
`processctl change review replace-reused` repairs only an initial pending assignment
|
|
90
|
+
proven to reuse another change's agent context, before a submitted review or normal
|
|
91
|
+
report file exists. It validates a fresh replacement and current verification,
|
|
92
|
+
preserves the prior assignment and conflict in canonical history, and leaves cycle
|
|
93
|
+
and evidence unchanged. Valid assignments and reviewed correction cycles cannot use
|
|
94
|
+
this operation. It does not provide an unrestricted reviewer reset.
|
|
95
|
+
|
|
80
96
|
Every delegated agent and reviewer follows the active user-selected model and
|
|
81
97
|
reasoning effort under [deliver-change's settings rule](process_assets/skills/deliver-change/SKILL.md#preserve-agent-execution-settings).
|
|
82
98
|
It covers fresh agents and resumed sessions, requires native runtime confirmation,
|
|
@@ -585,6 +601,16 @@ package rule keeps the adoption pull request in draft and runs exactly:
|
|
|
585
601
|
|
|
586
602
|
python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
|
|
587
603
|
|
|
604
|
+
The bootstrap refreshes `engineering-process` index metadata using pip's
|
|
605
|
+
`--refresh-package` option, with `--no-cache-dir` for older pip. Exact pins, hashes,
|
|
606
|
+
isolated installation and bounded failures still apply. Consumer CI that installs
|
|
607
|
+
the lock directly must also request fresh metadata: use
|
|
608
|
+
`--refresh-package engineering-process` with pip 26.2 or newer, or the portable
|
|
609
|
+
`--no-cache-dir` option. Keep this setting on the process installation command;
|
|
610
|
+
other dependency caches do not need to be disabled. For non-isolated pip-compile,
|
|
611
|
+
the equivalent `PIP_REFRESH_PACKAGE=engineering-process` environment setting keeps
|
|
612
|
+
fresh release lookup compatible with older pip, which revalidated by default.
|
|
613
|
+
|
|
588
614
|
postUpgradeTasks.fileFilters includes the managed paths, so Renovate commits the new
|
|
589
615
|
hash lock and the fully materialized process in the same pull request. A self-hosted
|
|
590
616
|
Renovate administrator must allow only this anchored command and must keep shell
|
|
@@ -40,19 +40,35 @@ under .process/receipts. Both paths are ignored by Git. Verification evidence is
|
|
|
40
40
|
to HEAD plus a fingerprint of tracked and non-ignored untracked files. Any relevant
|
|
41
41
|
mutation invalidates it.
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
Neither the reviewer actor nor reviewer context may have implemented the current
|
|
44
|
+
cycle. An agent reviewer context also cannot be recorded in another accepted change
|
|
45
|
+
in this repository or its registered worktrees. There is no attestation hierarchy,
|
|
45
46
|
recommendation chain, authority-transition protocol, remote-evidence federation, or
|
|
46
47
|
second handwritten validator.
|
|
47
48
|
|
|
48
|
-
The coordinator hands
|
|
49
|
-
|
|
50
|
-
reviewer continues corrections. The reviewer inspects the change and authors its
|
|
49
|
+
The coordinator hands each new change to a newly spawned reviewer, with the accepted
|
|
50
|
+
source artifacts and no inherited implementation or other-change review conversation.
|
|
51
|
+
The same reviewer continues only that change's corrections. The reviewer inspects the change and authors its
|
|
51
52
|
own report. The runner's existing task/session interaction and returned result make
|
|
52
53
|
that work inspectable. Sharing a provider or account is allowed; this is a
|
|
53
54
|
workflow for independent judgment, not authenticated identity or merge enforcement.
|
|
54
55
|
See [change-review](process_assets/skills/change-review/SKILL.md) for the handoff.
|
|
55
56
|
|
|
57
|
+
Start, submission and finish check schema-valid canonical run history with bounded
|
|
58
|
+
worktree/file reads. A short OS lock in common Git metadata serializes canonical
|
|
59
|
+
history writes and review checks; profiles run outside it and reload current state
|
|
60
|
+
before recording results. The lock stores no identity registry. The check rejects known context reuse,
|
|
61
|
+
but cannot establish native conversation freshness or inspect removed history,
|
|
62
|
+
other clones or other repositories. Keep native creation, non-inherited dispatch
|
|
63
|
+
and effective settings evidence in the existing handoff.
|
|
64
|
+
|
|
65
|
+
`processctl change review replace-reused` repairs only an initial pending assignment
|
|
66
|
+
proven to reuse another change's agent context, before a submitted review or normal
|
|
67
|
+
report file exists. It validates a fresh replacement and current verification,
|
|
68
|
+
preserves the prior assignment and conflict in canonical history, and leaves cycle
|
|
69
|
+
and evidence unchanged. Valid assignments and reviewed correction cycles cannot use
|
|
70
|
+
this operation. It does not provide an unrestricted reviewer reset.
|
|
71
|
+
|
|
56
72
|
Every delegated agent and reviewer follows the active user-selected model and
|
|
57
73
|
reasoning effort under [deliver-change's settings rule](process_assets/skills/deliver-change/SKILL.md#preserve-agent-execution-settings).
|
|
58
74
|
It covers fresh agents and resumed sessions, requires native runtime confirmation,
|
|
@@ -561,6 +577,16 @@ package rule keeps the adoption pull request in draft and runs exactly:
|
|
|
561
577
|
|
|
562
578
|
python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
|
|
563
579
|
|
|
580
|
+
The bootstrap refreshes `engineering-process` index metadata using pip's
|
|
581
|
+
`--refresh-package` option, with `--no-cache-dir` for older pip. Exact pins, hashes,
|
|
582
|
+
isolated installation and bounded failures still apply. Consumer CI that installs
|
|
583
|
+
the lock directly must also request fresh metadata: use
|
|
584
|
+
`--refresh-package engineering-process` with pip 26.2 or newer, or the portable
|
|
585
|
+
`--no-cache-dir` option. Keep this setting on the process installation command;
|
|
586
|
+
other dependency caches do not need to be disabled. For non-isolated pip-compile,
|
|
587
|
+
the equivalent `PIP_REFRESH_PACKAGE=engineering-process` environment setting keeps
|
|
588
|
+
fresh release lookup compatible with older pip, which revalidated by default.
|
|
589
|
+
|
|
564
590
|
postUpgradeTasks.fileFilters includes the managed paths, so Renovate commits the new
|
|
565
591
|
hash lock and the fully materialized process in the same pull request. A self-hosted
|
|
566
592
|
Renovate administrator must allow only this anchored command and must keep shell
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Engineering Process v2.3.1
|
|
2
|
+
|
|
3
|
+
Changes since v2.3.0.
|
|
4
|
+
|
|
5
|
+
## Fixes
|
|
6
|
+
|
|
7
|
+
- Refresh package metadata when installing the hash\-locked process so newly published releases remain visible through warm pip caches\; remove the publication\-time cache expiry wait\. Consumer CI using pip 26\.2 or newer should pass \-\-refresh\-package engineering\-process\, or use \-\-no\-cache\-dir with older pip\. ([#134](https://github.com/phuongnse/engineering-process/issues/134))
|
|
8
|
+
- Reject recorded agent reviewer\-context reuse across changes in the same repository and its worktrees\. Preserve same\-change correction reviews\, and provide an audited replacement only for a reused initial assignment with no submitted review\. Native fresh\-session and unchanged model\/effort evidence remain required from the host\. ([#182](https://github.com/phuongnse/engineering-process/issues/182))
|
|
9
|
+
|
|
10
|
+
## Upgrade and compatibility
|
|
11
|
+
|
|
12
|
+
Merge the complete hash-locked package/adoption PR, update the local and CI environments to the selected version, and start a fresh agent session.
|
|
13
|
+
|
|
14
|
+
Consumer CI, naming conventions and branch-protection settings remain consumer-owned; adoption does not configure them automatically.
|
|
15
|
+
|
|
16
|
+
See [versioning and compatibility](https://github.com/phuongnse/engineering-process/blob/v2.3.1/VERSIONING.md) and [adoption guidance](https://github.com/phuongnse/engineering-process/blob/v2.3.1/SELF_HOSTING.md).
|
|
17
|
+
|
|
18
|
+
[Full change comparison](https://github.com/phuongnse/engineering-process/compare/v2.3.0...v2.3.1)
|
|
@@ -330,9 +330,10 @@ def command_change_review_start(args: argparse.Namespace) -> Result:
|
|
|
330
330
|
actor_id=args.actor,
|
|
331
331
|
context_id=args.context,
|
|
332
332
|
kind=args.actor_kind,
|
|
333
|
+
replace_reused=args.review_command == "replace-reused",
|
|
333
334
|
)
|
|
334
335
|
return _state_result(
|
|
335
|
-
"change review
|
|
336
|
+
f"change review {args.review_command}",
|
|
336
337
|
state,
|
|
337
338
|
assignment=state["reviewAssignment"],
|
|
338
339
|
processSignals=process_improvement_signals(state),
|
|
@@ -542,6 +543,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
542
543
|
review_start = _leaf(review_commands, "start", command_change_review_start)
|
|
543
544
|
_add_actor(review_start)
|
|
544
545
|
review_start.add_argument("--change-id", required=True)
|
|
546
|
+
review_replace = _leaf(review_commands, "replace-reused", command_change_review_start)
|
|
547
|
+
_add_actor(review_replace)
|
|
548
|
+
review_replace.add_argument("--change-id", required=True)
|
|
545
549
|
review_submit = _leaf(review_commands, "submit", command_change_review_submit)
|
|
546
550
|
review_submit.add_argument("--change-id", required=True)
|
|
547
551
|
review_submit.add_argument("--review", type=Path, required=True)
|
|
@@ -51,9 +51,20 @@ def read_json(path: Path, *, maximum_bytes: int = MAX_JSON_BYTES) -> Any:
|
|
|
51
51
|
if size > maximum_bytes:
|
|
52
52
|
raise ProcessError(f"{path} exceeds {maximum_bytes} bytes")
|
|
53
53
|
try:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
with path.open("rb") as stream:
|
|
55
|
+
content = stream.read(maximum_bytes + 1)
|
|
56
|
+
except OSError as error:
|
|
57
|
+
raise ProcessError(f"cannot read {path}: {error}") from error
|
|
58
|
+
if len(content) > maximum_bytes:
|
|
59
|
+
raise ProcessError(f"{path} exceeds {maximum_bytes} bytes")
|
|
60
|
+
return parse_json_bytes(content, source=str(path))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_json_bytes(content: bytes, *, source: str) -> Any:
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(content.decode("utf-8"))
|
|
66
|
+
except (UnicodeError, json.JSONDecodeError) as error:
|
|
67
|
+
raise ProcessError(f"{source} is not valid UTF-8 JSON: {error}") from error
|
|
57
68
|
|
|
58
69
|
|
|
59
70
|
def canonical_bytes(value: Any) -> bytes:
|
|
@@ -37,6 +37,11 @@ from .production_engineering import (
|
|
|
37
37
|
)
|
|
38
38
|
from .source_publication import branch_issues, current_branch, validate_current_source
|
|
39
39
|
from .repository import repository_snapshot, resolve_commit, same_checkpoint
|
|
40
|
+
from .review_contexts import (
|
|
41
|
+
recorded_context_conflict,
|
|
42
|
+
require_unreused_context,
|
|
43
|
+
history_transaction,
|
|
44
|
+
)
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
NEXT_COMMAND = {
|
|
@@ -134,6 +139,7 @@ def _require_phase(state: dict[str, Any], *phases: str) -> None:
|
|
|
134
139
|
)
|
|
135
140
|
|
|
136
141
|
|
|
142
|
+
@history_transaction
|
|
137
143
|
def start_change(
|
|
138
144
|
project_root: Path,
|
|
139
145
|
process_root: Path,
|
|
@@ -215,6 +221,7 @@ def start_change(
|
|
|
215
221
|
return state
|
|
216
222
|
|
|
217
223
|
|
|
224
|
+
@history_transaction
|
|
218
225
|
def register_plan(
|
|
219
226
|
project_root: Path,
|
|
220
227
|
process_root: Path,
|
|
@@ -251,6 +258,7 @@ def register_plan(
|
|
|
251
258
|
return state
|
|
252
259
|
|
|
253
260
|
|
|
261
|
+
@history_transaction
|
|
254
262
|
def begin_implementation(
|
|
255
263
|
project_root: Path,
|
|
256
264
|
process_root: Path,
|
|
@@ -322,13 +330,34 @@ def verify_change(
|
|
|
322
330
|
raise ProcessError(f"profile {profile} is not required by change {change_id}")
|
|
323
331
|
|
|
324
332
|
before = repository_snapshot(project_root)
|
|
333
|
+
report = run_profile(project_root, project, profile)
|
|
334
|
+
return _record_verification(
|
|
335
|
+
project_root, process_root, change_id, state["cycle"], before, report
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@history_transaction
|
|
340
|
+
def _record_verification(
|
|
341
|
+
project_root: Path,
|
|
342
|
+
process_root: Path,
|
|
343
|
+
change_id: str,
|
|
344
|
+
cycle: int,
|
|
345
|
+
before: dict[str, Any],
|
|
346
|
+
report: dict[str, Any],
|
|
347
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
348
|
+
# Profiles run outside the lock. Reload so their publication cannot erase
|
|
349
|
+
# participants or evidence recorded by another command while they ran.
|
|
350
|
+
state = _load_state(project_root, process_root, change_id)
|
|
351
|
+
_require_phase(state, "implementing")
|
|
352
|
+
if state["cycle"] != cycle:
|
|
353
|
+
raise ProcessError("implementation cycle changed while verification was running")
|
|
325
354
|
state["verification"] = {
|
|
326
355
|
name: report
|
|
327
356
|
for name, report in state["verification"].items()
|
|
328
357
|
if same_checkpoint(report["checkpoint"], before)
|
|
329
358
|
}
|
|
330
|
-
report = run_profile(project_root, project, profile)
|
|
331
359
|
after = repository_snapshot(project_root)
|
|
360
|
+
profile = report["profile"]
|
|
332
361
|
report["checkpoint"] = after
|
|
333
362
|
report["recordedAt"] = _now()
|
|
334
363
|
if not same_checkpoint(before, after):
|
|
@@ -356,6 +385,7 @@ def verify_change(
|
|
|
356
385
|
return state, report
|
|
357
386
|
|
|
358
387
|
|
|
388
|
+
@history_transaction
|
|
359
389
|
def start_review(
|
|
360
390
|
project_root: Path,
|
|
361
391
|
process_root: Path,
|
|
@@ -364,9 +394,29 @@ def start_review(
|
|
|
364
394
|
actor_id: str,
|
|
365
395
|
context_id: str,
|
|
366
396
|
kind: str,
|
|
397
|
+
replace_reused: bool = False,
|
|
367
398
|
) -> dict[str, Any]:
|
|
368
399
|
state = _load_state(project_root, process_root, change_id)
|
|
369
|
-
|
|
400
|
+
previous_assignment = conflict = None
|
|
401
|
+
if replace_reused:
|
|
402
|
+
_require_phase(state, "review-pending")
|
|
403
|
+
if (state["reviewAssignment"] is None or state["review"] is not None
|
|
404
|
+
or state["reviewHistory"]
|
|
405
|
+
or any(event["event"] == "review-submitted" for event in state["history"])):
|
|
406
|
+
raise ProcessError("only an unsubmitted initial review assignment can be replaced")
|
|
407
|
+
previous_assignment = deepcopy(state["reviewAssignment"])
|
|
408
|
+
if previous_assignment["reviewer"]["kind"] != "agent":
|
|
409
|
+
raise ProcessError("replace-reused requires a recorded reused agent context")
|
|
410
|
+
report_path = _run_path(project_root, change_id).with_name(f"review-{state['cycle']}.json")
|
|
411
|
+
if report_path.exists() or report_path.is_symlink():
|
|
412
|
+
raise ProcessError("a review report already exists; assignment replacement would discard it")
|
|
413
|
+
conflict = recorded_context_conflict(
|
|
414
|
+
project_root, process_root, state, previous_assignment["reviewer"]["contextId"]
|
|
415
|
+
)
|
|
416
|
+
if conflict is None:
|
|
417
|
+
raise ProcessError("the pending reviewer context has no recorded cross-change reuse")
|
|
418
|
+
else:
|
|
419
|
+
_require_phase(state, "verified")
|
|
370
420
|
reviewer = _actor(actor_id, context_id, kind)
|
|
371
421
|
if state["reviewHistory"]:
|
|
372
422
|
original_reviewer = state["reviewHistory"][0]["document"]["reviewer"]
|
|
@@ -383,6 +433,7 @@ def start_review(
|
|
|
383
433
|
raise ProcessError("reviewer actor must be independent from implementation")
|
|
384
434
|
if any(item["contextId"] == reviewer["contextId"] for item in implementers):
|
|
385
435
|
raise ProcessError("reviewer context must be independent from implementation")
|
|
436
|
+
require_unreused_context(project_root, process_root, state, reviewer)
|
|
386
437
|
|
|
387
438
|
checkpoint = repository_snapshot(project_root)
|
|
388
439
|
required = state["contract"]["document"]["requiredProfiles"]
|
|
@@ -393,6 +444,10 @@ def start_review(
|
|
|
393
444
|
for name in required
|
|
394
445
|
):
|
|
395
446
|
raise ProcessError("verification evidence is stale or incomplete")
|
|
447
|
+
if previous_assignment is not None and not same_checkpoint(
|
|
448
|
+
previous_assignment["checkpoint"], checkpoint
|
|
449
|
+
):
|
|
450
|
+
raise ProcessError("repository changed after the reused review assignment")
|
|
396
451
|
state["reviewAssignment"] = {
|
|
397
452
|
"reviewer": reviewer,
|
|
398
453
|
"checkpoint": checkpoint,
|
|
@@ -403,11 +458,21 @@ def start_review(
|
|
|
403
458
|
),
|
|
404
459
|
}
|
|
405
460
|
state["phase"] = "review-pending"
|
|
406
|
-
|
|
461
|
+
if previous_assignment is None:
|
|
462
|
+
_event(state, "review-started", reviewer)
|
|
463
|
+
else:
|
|
464
|
+
_event(
|
|
465
|
+
state, "review-assignment-replaced", reviewer,
|
|
466
|
+
reason="cross-change-context-reuse",
|
|
467
|
+
previousAssignment=previous_assignment,
|
|
468
|
+
replacementAssignment=deepcopy(state["reviewAssignment"]),
|
|
469
|
+
conflict=conflict,
|
|
470
|
+
)
|
|
407
471
|
_save_state(project_root, process_root, state)
|
|
408
472
|
return state
|
|
409
473
|
|
|
410
474
|
|
|
475
|
+
@history_transaction
|
|
411
476
|
def submit_review(
|
|
412
477
|
project_root: Path,
|
|
413
478
|
process_root: Path,
|
|
@@ -428,6 +493,7 @@ def submit_review(
|
|
|
428
493
|
raise ProcessError("review changeId does not match lifecycle state")
|
|
429
494
|
if review["reviewer"] != assignment["reviewer"]:
|
|
430
495
|
raise ProcessError("reviewer does not match the assigned independent identity")
|
|
496
|
+
require_unreused_context(project_root, process_root, state, assignment["reviewer"])
|
|
431
497
|
if not same_checkpoint(review["checkpoint"], assignment["checkpoint"]):
|
|
432
498
|
raise ProcessError("review checkpoint does not match the assignment")
|
|
433
499
|
current = repository_snapshot(project_root)
|
|
@@ -535,6 +601,7 @@ def submit_review(
|
|
|
535
601
|
return state
|
|
536
602
|
|
|
537
603
|
|
|
604
|
+
@history_transaction
|
|
538
605
|
def finish_change(
|
|
539
606
|
project_root: Path,
|
|
540
607
|
process_root: Path,
|
|
@@ -546,6 +613,9 @@ def finish_change(
|
|
|
546
613
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
547
614
|
state = _load_state(project_root, process_root, change_id)
|
|
548
615
|
_require_phase(state, "approved")
|
|
616
|
+
require_unreused_context(
|
|
617
|
+
project_root, process_root, state, state["reviewAssignment"]["reviewer"]
|
|
618
|
+
)
|
|
549
619
|
actor = _actor(actor_id, context_id, kind)
|
|
550
620
|
project = load_project(project_root, process_root)
|
|
551
621
|
checkpoint = repository_snapshot(project_root)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Recorded reviewer-context ownership within one Git repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from functools import wraps
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Iterator
|
|
14
|
+
|
|
15
|
+
from .contracts import (
|
|
16
|
+
MAX_JSON_BYTES,
|
|
17
|
+
ProcessError,
|
|
18
|
+
digest_json,
|
|
19
|
+
parse_json_bytes,
|
|
20
|
+
validate_document,
|
|
21
|
+
)
|
|
22
|
+
from .distribution import schemas_root
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MAX_WORKTREES = 64
|
|
26
|
+
MAX_RUN_ENTRIES = 512
|
|
27
|
+
MAX_TOTAL_BYTES = 64_000_000
|
|
28
|
+
MAX_GIT_BYTES = 256_000
|
|
29
|
+
GIT_TIMEOUT_SECONDS = 10
|
|
30
|
+
SCAN_TIMEOUT_SECONDS = 30
|
|
31
|
+
LOCK_TIMEOUT_SECONDS = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_deadline(deadline: float) -> None:
|
|
35
|
+
if time.monotonic() >= deadline:
|
|
36
|
+
raise ProcessError("review history exceeds its time limit")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _git(root: Path, arguments: list[str], *, deadline: float | None = None) -> bytes:
|
|
40
|
+
# These fixed metadata commands cannot invoke a pager or consumer shell command.
|
|
41
|
+
command_deadline = time.monotonic() + GIT_TIMEOUT_SECONDS
|
|
42
|
+
if deadline is not None:
|
|
43
|
+
_check_deadline(deadline)
|
|
44
|
+
command_deadline = min(command_deadline, deadline)
|
|
45
|
+
with tempfile.TemporaryFile() as output:
|
|
46
|
+
try:
|
|
47
|
+
process = subprocess.Popen(
|
|
48
|
+
["git", "--no-pager", *arguments], cwd=root,
|
|
49
|
+
stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT,
|
|
50
|
+
)
|
|
51
|
+
except OSError as error:
|
|
52
|
+
raise ProcessError(f"cannot inspect review worktrees: {error}") from error
|
|
53
|
+
try:
|
|
54
|
+
while process.poll() is None:
|
|
55
|
+
if os.fstat(output.fileno()).st_size > MAX_GIT_BYTES:
|
|
56
|
+
raise ProcessError("review worktree Git output exceeded its byte limit")
|
|
57
|
+
if time.monotonic() >= command_deadline:
|
|
58
|
+
raise ProcessError("review worktree Git command timed out")
|
|
59
|
+
time.sleep(0.01)
|
|
60
|
+
output.seek(0)
|
|
61
|
+
content = output.read(MAX_GIT_BYTES + 1)
|
|
62
|
+
if len(content) > MAX_GIT_BYTES:
|
|
63
|
+
raise ProcessError("review worktree Git output exceeded its byte limit")
|
|
64
|
+
if process.returncode:
|
|
65
|
+
detail = content[:2000].decode("utf-8", errors="replace").strip()
|
|
66
|
+
raise ProcessError(f"cannot inspect review worktrees: {detail}")
|
|
67
|
+
if deadline is not None:
|
|
68
|
+
_check_deadline(deadline)
|
|
69
|
+
return content
|
|
70
|
+
finally:
|
|
71
|
+
if process.poll() is None:
|
|
72
|
+
process.kill()
|
|
73
|
+
process.wait(timeout=5)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _safe_info(path: Path, *, directory: bool) -> os.stat_result:
|
|
77
|
+
info = path.lstat()
|
|
78
|
+
reparse = getattr(info, "st_file_attributes", 0) & getattr(
|
|
79
|
+
stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0
|
|
80
|
+
)
|
|
81
|
+
if stat.S_ISLNK(info.st_mode) or reparse:
|
|
82
|
+
raise ProcessError(f"review history must not traverse a link or reparse point: {path}")
|
|
83
|
+
expected = stat.S_ISDIR if directory else stat.S_ISREG
|
|
84
|
+
if not expected(info.st_mode):
|
|
85
|
+
raise ProcessError(f"review history has an unsupported file type: {path}")
|
|
86
|
+
return info
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _identity(info: os.stat_result) -> tuple[int, ...]:
|
|
90
|
+
return (info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _directory_identity(path: Path) -> tuple[int, ...]:
|
|
94
|
+
info = _safe_info(path, directory=True)
|
|
95
|
+
return (info.st_dev, info.st_ino, info.st_mode)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _common_directory(root: Path, *, deadline: float | None = None) -> Path:
|
|
99
|
+
result = _git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
100
|
+
deadline=deadline)
|
|
101
|
+
path = Path(os.fsdecode(result.removesuffix(b"\n")))
|
|
102
|
+
if not path.is_absolute():
|
|
103
|
+
raise ProcessError("Git common directory must be absolute")
|
|
104
|
+
_safe_info(path, directory=True)
|
|
105
|
+
return path.resolve()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def _review_lock(root: Path) -> Iterator[None]:
|
|
110
|
+
common = _common_directory(root)
|
|
111
|
+
path = common / "engineering-process-review.lock"
|
|
112
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
|
113
|
+
descriptor = os.open(path, flags, 0o600)
|
|
114
|
+
try:
|
|
115
|
+
opened = os.fstat(descriptor)
|
|
116
|
+
current = _safe_info(path, directory=False)
|
|
117
|
+
if (current.st_dev, current.st_ino, current.st_mode) != (
|
|
118
|
+
opened.st_dev, opened.st_ino, opened.st_mode
|
|
119
|
+
):
|
|
120
|
+
raise ProcessError("review transaction lock changed while opening")
|
|
121
|
+
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
|
|
122
|
+
while True:
|
|
123
|
+
try:
|
|
124
|
+
if os.name == "nt":
|
|
125
|
+
import msvcrt
|
|
126
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
127
|
+
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
|
|
128
|
+
else:
|
|
129
|
+
import fcntl
|
|
130
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
131
|
+
break
|
|
132
|
+
except OSError as error:
|
|
133
|
+
if time.monotonic() >= deadline:
|
|
134
|
+
raise ProcessError("review transaction lock timed out") from error
|
|
135
|
+
time.sleep(0.02)
|
|
136
|
+
try:
|
|
137
|
+
current = _safe_info(path, directory=False)
|
|
138
|
+
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
139
|
+
raise ProcessError("review transaction lock was replaced")
|
|
140
|
+
yield
|
|
141
|
+
finally:
|
|
142
|
+
if os.name == "nt":
|
|
143
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
144
|
+
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
|
145
|
+
else:
|
|
146
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
147
|
+
finally:
|
|
148
|
+
os.close(descriptor)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def history_transaction(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
152
|
+
@wraps(function)
|
|
153
|
+
def locked(project_root: Path, *args: Any, **kwargs: Any) -> Any:
|
|
154
|
+
try:
|
|
155
|
+
with _review_lock(project_root):
|
|
156
|
+
return function(project_root, *args, **kwargs)
|
|
157
|
+
except OSError as error:
|
|
158
|
+
raise ProcessError(f"cannot inspect or lock lifecycle history: {error}") from error
|
|
159
|
+
return locked
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _worktrees(root: Path, *, deadline: float) -> list[Path]:
|
|
163
|
+
raw = _git(root, ["worktree", "list", "--porcelain", "-z"], deadline=deadline)
|
|
164
|
+
roots = []
|
|
165
|
+
for record in raw.split(b"\0\0"):
|
|
166
|
+
_check_deadline(deadline)
|
|
167
|
+
if not record:
|
|
168
|
+
continue
|
|
169
|
+
fields = record.split(b"\0")
|
|
170
|
+
if not fields[0].startswith(b"worktree "):
|
|
171
|
+
raise ProcessError("Git returned an invalid review worktree record")
|
|
172
|
+
if b"bare" in fields:
|
|
173
|
+
continue
|
|
174
|
+
path = Path(os.fsdecode(fields[0][len(b"worktree "):]))
|
|
175
|
+
if not path.is_absolute():
|
|
176
|
+
raise ProcessError("review worktree paths must be absolute")
|
|
177
|
+
_safe_info(path, directory=True)
|
|
178
|
+
roots.append(path.resolve())
|
|
179
|
+
if len(roots) > MAX_WORKTREES:
|
|
180
|
+
raise ProcessError("review history exceeds its worktree limit")
|
|
181
|
+
if root.resolve() not in roots or len(roots) != len(set(roots)):
|
|
182
|
+
raise ProcessError("Git returned an incomplete or duplicate review worktree inventory")
|
|
183
|
+
return sorted(roots)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _read_run(path: Path, parents: list[Path], process_root: Path) -> tuple[dict[str, Any], int]:
|
|
187
|
+
before = [_directory_identity(parent) for parent in parents]
|
|
188
|
+
info = _safe_info(path, directory=False)
|
|
189
|
+
if info.st_size > MAX_JSON_BYTES:
|
|
190
|
+
raise ProcessError(f"{path} exceeds {MAX_JSON_BYTES} bytes")
|
|
191
|
+
descriptor = os.open(
|
|
192
|
+
path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
193
|
+
| getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0),
|
|
194
|
+
)
|
|
195
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
196
|
+
if _identity(os.fstat(stream.fileno())) != _identity(info):
|
|
197
|
+
raise ProcessError(f"review history changed while opening: {path}")
|
|
198
|
+
if before != [_directory_identity(parent) for parent in parents]:
|
|
199
|
+
raise ProcessError(f"review history path changed while opening: {path}")
|
|
200
|
+
content = stream.read(MAX_JSON_BYTES + 1)
|
|
201
|
+
after = _safe_info(path, directory=False)
|
|
202
|
+
if _identity(after) != _identity(info) or len(content) != info.st_size:
|
|
203
|
+
raise ProcessError(f"review history changed while reading: {path}")
|
|
204
|
+
if before != [_directory_identity(parent) for parent in parents]:
|
|
205
|
+
raise ProcessError(f"review history path changed while reading: {path}")
|
|
206
|
+
state = validate_document(
|
|
207
|
+
parse_json_bytes(content, source=str(path)), "run",
|
|
208
|
+
schema_root=schemas_root(process_root), source=str(path),
|
|
209
|
+
)
|
|
210
|
+
if state["changeId"] != path.parent.name:
|
|
211
|
+
raise ProcessError(f"{path}: change identity mismatch")
|
|
212
|
+
return state, len(content)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def recorded_context_conflict(
|
|
216
|
+
project_root: Path, process_root: Path, current: dict[str, Any], context_id: str,
|
|
217
|
+
) -> dict[str, str] | None:
|
|
218
|
+
"""Return one recorded foreign use; absence only covers the inspected history."""
|
|
219
|
+
deadline = time.monotonic() + SCAN_TIMEOUT_SECONDS
|
|
220
|
+
common = _common_directory(project_root, deadline=deadline)
|
|
221
|
+
roots = _worktrees(project_root, deadline=deadline)
|
|
222
|
+
accepted = (current["changeId"], current["contract"]["digest"])
|
|
223
|
+
total = entries = 0
|
|
224
|
+
for root in roots:
|
|
225
|
+
_check_deadline(deadline)
|
|
226
|
+
if _common_directory(root, deadline=deadline) != common:
|
|
227
|
+
raise ProcessError(f"registered review worktree changed repository: {root}")
|
|
228
|
+
process = root / ".process"
|
|
229
|
+
runs = process / "runs"
|
|
230
|
+
try:
|
|
231
|
+
_safe_info(process, directory=True)
|
|
232
|
+
_safe_info(runs, directory=True)
|
|
233
|
+
except FileNotFoundError:
|
|
234
|
+
_check_deadline(deadline)
|
|
235
|
+
continue
|
|
236
|
+
directories = []
|
|
237
|
+
with os.scandir(runs) as children:
|
|
238
|
+
for child in children:
|
|
239
|
+
entries += 1
|
|
240
|
+
if entries > MAX_RUN_ENTRIES or time.monotonic() >= deadline:
|
|
241
|
+
raise ProcessError("review history exceeds its entry or time limit")
|
|
242
|
+
directories.append(Path(child.path))
|
|
243
|
+
for directory in sorted(directories):
|
|
244
|
+
_check_deadline(deadline)
|
|
245
|
+
info = directory.lstat()
|
|
246
|
+
if stat.S_ISREG(info.st_mode):
|
|
247
|
+
continue
|
|
248
|
+
_safe_info(directory, directory=True)
|
|
249
|
+
path = directory / "run.json"
|
|
250
|
+
try:
|
|
251
|
+
path.lstat()
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
continue
|
|
254
|
+
state, size = _read_run(path, [root, process, runs, directory], process_root)
|
|
255
|
+
_check_deadline(deadline)
|
|
256
|
+
total += size
|
|
257
|
+
if total > MAX_TOTAL_BYTES:
|
|
258
|
+
raise ProcessError("review history exceeds its aggregate byte limit")
|
|
259
|
+
if (state["changeId"], state["contract"]["digest"]) == accepted:
|
|
260
|
+
continue
|
|
261
|
+
actors = [event["actor"] for event in state["history"]]
|
|
262
|
+
actors.extend(item["actor"] for item in state["implementations"])
|
|
263
|
+
if state["reviewAssignment"] is not None:
|
|
264
|
+
actors.append(state["reviewAssignment"]["reviewer"])
|
|
265
|
+
for event in state["history"]:
|
|
266
|
+
if event["event"] == "review-assignment-replaced":
|
|
267
|
+
actors.append(event["details"]["previousAssignment"]["reviewer"])
|
|
268
|
+
if any(actor["contextId"] == context_id for actor in actors):
|
|
269
|
+
return {
|
|
270
|
+
"changeId": state["changeId"],
|
|
271
|
+
"contractDigest": state["contract"]["digest"],
|
|
272
|
+
"runPath": str(path),
|
|
273
|
+
"runDigest": digest_json(state),
|
|
274
|
+
}
|
|
275
|
+
if _worktrees(project_root, deadline=deadline) != roots:
|
|
276
|
+
raise ProcessError("review worktree inventory changed during inspection")
|
|
277
|
+
_check_deadline(deadline)
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def require_unreused_context(
|
|
282
|
+
project_root: Path, process_root: Path, state: dict[str, Any], reviewer: dict[str, str],
|
|
283
|
+
) -> None:
|
|
284
|
+
if reviewer["kind"] != "agent":
|
|
285
|
+
return
|
|
286
|
+
conflict = recorded_context_conflict(project_root, process_root, state, reviewer["contextId"])
|
|
287
|
+
if conflict is not None:
|
|
288
|
+
raise ProcessError(
|
|
289
|
+
f"reviewer context was used by another change: {conflict['changeId']} "
|
|
290
|
+
f"({conflict['runPath']}); spawn a fresh reviewer for this change"
|
|
291
|
+
)
|