agentic-devtools 0.2.595__py3-none-any.whl → 0.2.596__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.
- agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/SKILL.md +74 -10
- agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/specification-templates.md +31 -0
- agentic_devtools/_version.py +2 -2
- agentic_devtools/cli/ci/github_provider.py +23 -15
- agentic_devtools/cli/ci/initial_delivery_claims.py +227 -0
- agentic_devtools/cli/ci/pipeline/actions/initial_delivery_recovery.py +500 -0
- agentic_devtools/cli/ci/pipeline/actions/retry_failed_session.py +10 -28
- agentic_devtools/cli/ci/pipeline/delivery.py +18 -0
- agentic_devtools/cli/ci/pipeline/snapshot_models.py +3 -0
- agentic_devtools/cli/ci/provider_events.py +4 -0
- agentic_devtools/cli/ci/provider_pr_lifecycle.py +8 -0
- {agentic_devtools-0.2.595.dist-info → agentic_devtools-0.2.596.dist-info}/METADATA +1 -1
- {agentic_devtools-0.2.595.dist-info → agentic_devtools-0.2.596.dist-info}/RECORD +16 -14
- {agentic_devtools-0.2.595.dist-info → agentic_devtools-0.2.596.dist-info}/WHEEL +0 -0
- {agentic_devtools-0.2.595.dist-info → agentic_devtools-0.2.596.dist-info}/entry_points.txt +0 -0
- {agentic_devtools-0.2.595.dist-info → agentic_devtools-0.2.596.dist-info}/licenses/LICENSE +0 -0
|
@@ -249,7 +249,7 @@ An item is **unresolvable** if:
|
|
|
249
249
|
- It depends on external behavior that remains unsupported after checking authoritative
|
|
250
250
|
documentation or pinned source for the relevant version and effective project configuration.
|
|
251
251
|
Repository absence alone is not enough to make the behavior unresolvable. Before classifying it,
|
|
252
|
-
apply the evidence procedure in [Phase 3.3, step
|
|
252
|
+
apply the evidence procedure in [Phase 3.3, step 5](#33-feasibility-and-evidence-gate). If the
|
|
253
253
|
evidence establishes the behavior, record its citation and whether it was **source-verified** or
|
|
254
254
|
**exercised** in the clarification log, resolve the ambiguity, and continue. If evidence is
|
|
255
255
|
missing, inaccessible, inapplicable, contradictory, or insufficient to establish effective
|
|
@@ -318,28 +318,60 @@ instructions and validators, and authoritative external sources for any claimed
|
|
|
318
318
|
tests, check the source-to-test folder mapping, directory depth, package markers, and the
|
|
319
319
|
validator itself rather than relying only on a wrapper or confidence label. Record the
|
|
320
320
|
authoritative path rule and evidence for each path.
|
|
321
|
-
4.
|
|
321
|
+
4. Assess physical-line capacity for every prescribed Python source or test file expected to
|
|
322
|
+
change or be created within the file-length guard's scope: repository-root `*.py` files and
|
|
323
|
+
files under `agentic_devtools/`, `scripts/`, `tests/`, `.github/`, `.agents/`, and `.agdt/`,
|
|
324
|
+
excluding guard-ignored runtime paths. Use `agentic_devtools/cli/checks/file_length.py` as the
|
|
325
|
+
authority for guarded roots, ignored paths, counting semantics, and the 500-line hard ceiling.
|
|
326
|
+
Record prescribed Python paths outside that scope as `not guarded`. For each existing guarded
|
|
327
|
+
file, count physical lines on the current default branch with the guard's semantics,
|
|
328
|
+
`sum(1 for _ in source_file)`; blank lines count, and nonblank-only counts are invalid.
|
|
329
|
+
Resolve the ceiling from `quality-baseline.json`'s `file_length` map using the repo-relative
|
|
330
|
+
destination POSIX path, whether or not the file currently exists: a recorded entry is the
|
|
331
|
+
ceiling, and only a missing entry uses 500 lines. Display an absent proposed file as `new`, but
|
|
332
|
+
use zero as its current count for projection and headroom calculations; this also applies when
|
|
333
|
+
recreating a path with a retained baseline entry. For a renamed or moved file, use the
|
|
334
|
+
destination path and its baseline entry, if any. Record current lines (or `new`), ceiling and
|
|
335
|
+
source, an evidence-based post-Ruff-format delta range, projected lines, headroom, and the
|
|
336
|
+
evidence and formatting assumptions/uncertainty supporting the estimate. Cite the default-
|
|
337
|
+
branch file-count evidence for existing files and the matching destination-path baseline entry,
|
|
338
|
+
or confirm that no entry exists. Ground the range in the specified behavior and comparable
|
|
339
|
+
existing code; projected lines are current lines plus the high bound, using zero as current
|
|
340
|
+
lines for a new file. Headroom is `max(0, ceiling - current lines)`, also using zero for a new
|
|
341
|
+
file; an existing baseline overrun has zero headroom. Mark the file `PASS` only when the
|
|
342
|
+
high-bound projection is at or below its ceiling. No fixed safety margin applies. If the range
|
|
343
|
+
cannot be grounded, mark it `UNVERIFIED`. When a projection exceeds its ceiling or is unverified,
|
|
344
|
+
correct the boundary toward a cohesive companion source/test module per
|
|
345
|
+
`docs/file-split-playbook.md` only when issue intent permits, preserving explicit exclusions
|
|
346
|
+
and public-contract constraints, then repeat this capacity assessment for the corrected paths.
|
|
347
|
+
Otherwise record `FAIL` or `UNVERIFIED` and use step 7's existing abort path. If no Python file
|
|
348
|
+
is prescribed, record `None — no Python files prescribed`; the capacity check passes trivially.
|
|
349
|
+
This is a content-level assessment, not deterministic code enforcement.
|
|
350
|
+
5. For every implementation-dependent claim about an external tool, build backend, service, or
|
|
322
351
|
API, cite authoritative documentation or source pinned to the relevant version/commit and
|
|
323
352
|
effective project configuration. State what the evidence establishes and whether it was
|
|
324
353
|
**source-verified** or **exercised**. A response field's name or type alone does not establish
|
|
325
354
|
semantics not established by its contract. If the relevant version/configuration or behavior is
|
|
326
355
|
not established, mark the claim unverified; a partial trace establishes neither support nor
|
|
327
356
|
impossibility.
|
|
328
|
-
|
|
329
|
-
|
|
357
|
+
6. Record the gate result using the format below, including the capacity evidence table from the
|
|
358
|
+
specification template. For each criterion, mark `PASS`, `FAIL`, or `UNVERIFIED` and cite the
|
|
359
|
+
trace, path authority, capacity evidence, or external evidence that supports the result.
|
|
330
360
|
Record `None` explicitly for empty result categories. Revisit correctable contradictions and
|
|
331
|
-
rerun the affected checks. A
|
|
332
|
-
|
|
333
|
-
deterministic code-level
|
|
334
|
-
|
|
361
|
+
rerun the affected checks. A capacity failure or unverified estimate prevents a passing result.
|
|
362
|
+
A pass requires every criterion and required path/claim to be evidenced, with no failed or
|
|
363
|
+
unverified item. This is a content-level workflow check, not a deterministic code-level
|
|
364
|
+
enforcement mechanism.
|
|
365
|
+
7. If multiple independent behaviors remain, use [Abort: Scope Violation](#abort-scope-violation).
|
|
335
366
|
For an unresolved product decision, explicit boundary conflict, missing authority, or missing
|
|
336
367
|
external evidence, use [Abort: Needs Human Input](#abort-needs-human-input). Include every
|
|
337
368
|
failed or unverified criterion, its evidence and trace, the conflicting or missing evidence,
|
|
338
369
|
and the decision/evidence needed to proceed. The escalation report is not an implementation-ready
|
|
339
370
|
specification; Phase 4 publication is available only after the gate passes.
|
|
340
|
-
|
|
371
|
+
8. After a pass, carry the gate result into the `Feasibility Gate Result` section of the
|
|
341
372
|
specification template. Label external evidence as source-verified or exercised. A passing
|
|
342
|
-
result must state that failed and unverified criteria are `None
|
|
373
|
+
result must state that failed and unverified criteria are `None` and include capacity evidence
|
|
374
|
+
for every prescribed guarded Python file.
|
|
343
375
|
|
|
344
376
|
Example gate result:
|
|
345
377
|
|
|
@@ -400,6 +432,38 @@ Evidence:
|
|
|
400
432
|
leaves the behavior unverified, and takes `Abort: Needs Human Input`; publication remains blocked.
|
|
401
433
|
Actual content walkthrough: the ambiguity is unresolvable, so refinement stops before
|
|
402
434
|
publication, and the Phase 3.3 gate is not bypassed or presumed to pass.
|
|
435
|
+
- **Reject or correct — #4571-style zero headroom and uncertain near-limit estimate.** Input:
|
|
436
|
+
(a) a hypothetical feasibility snapshot of an existing unbaselined module at 500 lines, as
|
|
437
|
+
historically evidenced for #4571 at `72c5fa9`, with a same-module-only post-Ruff estimate of
|
|
438
|
+
+1–3 lines including formatting uncertainty; (b) a near-limit unbaselined module at 495 lines
|
|
439
|
+
with a post-Ruff estimate of +3–7 lines including uncertainty, where the low-bound projection
|
|
440
|
+
is 498 but the high-bound projection is 502. Both ceilings are 500, with zero and five lines of
|
|
441
|
+
initial headroom respectively. Expected: neither case passes its prescribed boundary; correct
|
|
442
|
+
to a cohesive companion only if intent permits, otherwise fail/escalate through the existing
|
|
443
|
+
abort path. The historical #4571 evidence does not claim a later unretained implementation.
|
|
444
|
+
- **Reject — synthetic baseline ceiling projection grounded in #4470.** Input: a clearly synthetic
|
|
445
|
+
pre-dispatch scenario with current lines 606, baseline ceiling 606, and a +22-line estimate
|
|
446
|
+
(post-Ruff range +22–22, with no additional formatting uncertainty), projecting 628 and zero
|
|
447
|
+
initial headroom. The #4470 report is evidence of an implementation result at 628 against
|
|
448
|
+
baseline 606; it does not establish pre-change length. Expected: fail against the recorded
|
|
449
|
+
606-line ceiling, not the unbaselined 500-line ceiling; correct the boundary if authorized,
|
|
450
|
+
otherwise use the existing abort path.
|
|
451
|
+
- **Pass — sufficient baseline headroom.** Input: an existing file at 280 lines with a 320-line
|
|
452
|
+
baseline and an evidenced post-Ruff +20-line estimate (range +20–20, with no additional
|
|
453
|
+
formatting uncertainty), projecting 300. Initial headroom is 40 lines and remaining headroom is
|
|
454
|
+
20. Expected: pass this capacity check because the high-bound projection is within the baseline
|
|
455
|
+
ceiling.
|
|
456
|
+
- **Pass — new Python source and test ceilings.** Input: one proposed new source file with an
|
|
457
|
+
estimated post-Ruff size of 35–45 lines and one proposed new test file with 25–35 lines; neither
|
|
458
|
+
path exists in `quality-baseline.json`, and both ranges include formatting uncertainty. Expected:
|
|
459
|
+
assess both paths, show `new` and the 500-line ceiling for each, show 500 lines of initial
|
|
460
|
+
headroom, project 45 and 35 lines respectively, and pass their capacity checks. New test files
|
|
461
|
+
are not omitted.
|
|
462
|
+
- **Fail — recreated source and test paths with retained ceilings.** Input: one absent proposed
|
|
463
|
+
source path and one absent proposed test path, each still present in `quality-baseline.json` with
|
|
464
|
+
a 300-line ceiling, and each with an estimated post-Ruff delta of +400 lines. Expected: display
|
|
465
|
+
each current count as `new`, use zero for the calculation, show 300 lines of initial headroom and
|
|
466
|
+
a 400-line projection, and fail both capacity checks. Neither path may fall back to 500 or pass.
|
|
403
467
|
|
|
404
468
|
---
|
|
405
469
|
|
agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/specification-templates.md
CHANGED
|
@@ -42,6 +42,22 @@ Outcome: PASS — content-level workflow assessment, not deterministic enforceme
|
|
|
42
42
|
|---|---|---|
|
|
43
43
|
| {AC-1} | PASS | {Producer/behavior → consumer/wiring → observable output; path authority or external source, version/configuration, and source-verified/exercised status as applicable.} |
|
|
44
44
|
|
|
45
|
+
Capacity evidence:
|
|
46
|
+
|
|
47
|
+
| Path | Current lines (or new) | Ceiling (destination-path baseline or 500) | Estimated delta range | Projected lines | Headroom (`max(0, ceiling − current)`) | Result |
|
|
48
|
+
|---|---:|---:|---:|---:|---:|---|
|
|
49
|
+
| {repo-relative path} | {count or new} | {destination-path baseline count or 500 if no entry} | {+low–high lines} | {current + high; use 0 as current for new} | {max(0, ceiling − current); use 0 as current for new} | {PASS/FAIL/UNVERIFIED} |
|
|
50
|
+
|
|
51
|
+
Include every prescribed guarded Python source and test file expected to change or be created.
|
|
52
|
+
Resolve the ceiling by the repo-relative destination path, whether or not the file exists: a
|
|
53
|
+
matching baseline entry is the ceiling, and 500 applies only when no entry exists. Display an
|
|
54
|
+
absent file's current lines as `new`, but use zero as its current count for projection and headroom;
|
|
55
|
+
this includes recreated paths with retained baseline entries. For each existing file, cite its
|
|
56
|
+
default-branch line count. For each path, cite the matching destination-path baseline entry or
|
|
57
|
+
state that no entry exists. For each estimate, cite the behavior and comparable-code evidence, and
|
|
58
|
+
state the Ruff-format assumptions and uncertainty. Record prescribed paths outside the guard scope
|
|
59
|
+
as `not guarded`. If no Python file is prescribed, write `None — no Python files prescribed`.
|
|
60
|
+
|
|
45
61
|
Passed: {criterion IDs}. Failed: None. Unverified: None.
|
|
46
62
|
|
|
47
63
|
## Detailed Requirements
|
|
@@ -120,6 +136,21 @@ Outcome: PASS — content-level workflow assessment, not deterministic enforceme
|
|
|
120
136
|
||Criterion||Result||Evidence and trace||
|
|
121
137
|
|{AC-1}|PASS|{Producer/behavior → consumer/wiring → observable output; path authority or external source, version/configuration, and source-verified/exercised status as applicable.}|
|
|
122
138
|
|
|
139
|
+
Capacity evidence:
|
|
140
|
+
|
|
141
|
+
||Path||Current lines (or new)||Ceiling (destination-path baseline or 500)||Estimated delta range||Projected lines||Headroom (max(0, ceiling − current))||Result||
|
|
142
|
+
|{repo-relative path}|{count or new}|{destination-path baseline count or 500 if no entry}|{+low–high lines}|{current + high; use 0 as current for new}|{max(0, ceiling − current); use 0 as current for new}|{PASS/FAIL/UNVERIFIED}|
|
|
143
|
+
|
|
144
|
+
Include every prescribed guarded Python source and test file expected to change or be created.
|
|
145
|
+
Resolve the ceiling by the repo-relative destination path, whether or not the file exists: a
|
|
146
|
+
matching baseline entry is the ceiling, and 500 applies only when no entry exists. Display an
|
|
147
|
+
absent file's current lines as `new`, but use zero as its current count for projection and headroom;
|
|
148
|
+
this includes recreated paths with retained baseline entries. For each existing file, cite its
|
|
149
|
+
default-branch line count. For each path, cite the matching destination-path baseline entry or
|
|
150
|
+
state that no entry exists. For each estimate, cite the behavior and comparable-code evidence, and
|
|
151
|
+
state the Ruff-format assumptions and uncertainty. Record prescribed paths outside the guard scope
|
|
152
|
+
as `not guarded`. If no Python file is prescribed, write `None — no Python files prescribed`.
|
|
153
|
+
|
|
123
154
|
Passed: {criterion IDs}. Failed: None. Unverified: None.
|
|
124
155
|
|
|
125
156
|
h2. Detailed Requirements
|
agentic_devtools/_version.py
CHANGED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.596'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 596)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -410,6 +410,7 @@ _HUNK_HEADER_RE = re.compile(r"^@@ -(?P<old_start>\d+)(?:,(?P<old_count>\d+))? \
|
|
|
410
410
|
|
|
411
411
|
if TYPE_CHECKING:
|
|
412
412
|
from agentic_devtools.cli.audit.models import ClaimResult, ClosedPRInfo
|
|
413
|
+
from agentic_devtools.cli.ci.initial_delivery_claims import InitialDeliveryClaims
|
|
413
414
|
from agentic_devtools.cli.ci.reconciliation.models import CloudWriterRunInventory, ObservationOutcome, WorkflowRun
|
|
414
415
|
from agentic_devtools.cli.ci.scheduler import DispatchEvent, EligiblePR
|
|
415
416
|
|
|
@@ -10774,31 +10775,34 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
|
|
10774
10775
|
raise RuntimeError(f"Failed to post comment on issue #{issue_number}: response carried no comment ID")
|
|
10775
10776
|
return comment_id
|
|
10776
10777
|
|
|
10777
|
-
|
|
10778
|
+
def _get_initial_delivery_claims(self) -> InitialDeliveryClaims:
|
|
10779
|
+
from agentic_devtools.cli.ci.initial_delivery_claims import InitialDeliveryClaims
|
|
10780
|
+
|
|
10781
|
+
return InitialDeliveryClaims(self._repo)
|
|
10782
|
+
|
|
10778
10783
|
def dispatch_agent_retry(self, pr_number: int, prompt: str, failure_event_id: str) -> int:
|
|
10784
|
+
return self._dispatch_agent_retry(pr_number, prompt, failure_event_id)
|
|
10785
|
+
|
|
10786
|
+
@retry_with_backoff(max_retries=0)
|
|
10787
|
+
def _dispatch_agent_retry(self, pr_number: int, prompt: str, key: str, *, initial_delivery: bool = False) -> int:
|
|
10779
10788
|
"""Post one @copilot-first retry comment with its marker using the workflow token."""
|
|
10780
|
-
from agentic_devtools.cli.ci.guards import
|
|
10781
|
-
_AGENT_RETRY_IDENTITY_MARKER_RE,
|
|
10782
|
-
_agent_retry_comment_header,
|
|
10783
|
-
)
|
|
10789
|
+
from agentic_devtools.cli.ci.guards import _AGENT_RETRY_IDENTITY_MARKER_RE, _agent_retry_comment_header
|
|
10784
10790
|
|
|
10785
|
-
|
|
10791
|
+
namespace = "initial-delivery-retry" if initial_delivery else "agent-retry"
|
|
10792
|
+
marker_pattern = re.compile(rf"<!-- agdt:{namespace}:{re.escape(key)}:(\d+) -->")
|
|
10786
10793
|
metadata, _, context = prompt.partition("\n\n")
|
|
10787
10794
|
marker_match = marker_pattern.search(metadata)
|
|
10788
10795
|
if marker_match is None:
|
|
10789
10796
|
raise ValueError("agent retry prompt is missing its failure marker")
|
|
10790
10797
|
marker = marker_match.group(0)
|
|
10791
10798
|
alias_pattern = re.compile(r"<!-- agdt:agent-retry-alias:([^:\s>]+) -->")
|
|
10792
|
-
alias_markers = [
|
|
10793
|
-
f"<!-- agdt:agent-retry-alias:{alias} -->" for alias in dict.fromkeys(alias_pattern.findall(metadata))
|
|
10794
|
-
]
|
|
10799
|
+
alias_markers = [f"<!-- agdt:agent-retry-alias:{a} -->" for a in dict.fromkeys(alias_pattern.findall(metadata))]
|
|
10795
10800
|
identity_markers = [
|
|
10796
10801
|
f"<!-- agdt:agent-retry-identity:{source}:{identity} -->"
|
|
10797
10802
|
for source, identity in dict.fromkeys(_AGENT_RETRY_IDENTITY_MARKER_RE.findall(metadata))
|
|
10798
10803
|
]
|
|
10799
|
-
content = re.sub(
|
|
10800
|
-
content = alias_pattern.sub("", content)
|
|
10801
|
-
content = _AGENT_RETRY_IDENTITY_MARKER_RE.sub("", content)
|
|
10804
|
+
content = re.sub(rf"<!-- agdt:{namespace}:[^:\s>]+:\d+ -->", "", metadata)
|
|
10805
|
+
content = _AGENT_RETRY_IDENTITY_MARKER_RE.sub("", alias_pattern.sub("", content))
|
|
10802
10806
|
content = "\n\n".join(part.strip() for part in (content, context) if part.strip())
|
|
10803
10807
|
content = re.sub(r"@copilot", "", content, flags=re.IGNORECASE).strip()
|
|
10804
10808
|
retry_header = "\n".join([marker, *alias_markers, *identity_markers])
|
|
@@ -10808,8 +10812,7 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
|
|
10808
10812
|
body = _enforce_comment_size_limit(body)
|
|
10809
10813
|
if len(body) > _MAX_COMMENT_BODY_CHARS:
|
|
10810
10814
|
suffix = "\n\n[Retry context truncated to fit GitHub's comment limit.]"
|
|
10811
|
-
|
|
10812
|
-
body = f"{prefix}{content[: max(0, _MAX_COMMENT_BODY_CHARS - reserved)]}{suffix}"
|
|
10815
|
+
body = f"{prefix}{content[: max(0, _MAX_COMMENT_BODY_CHARS - len(prefix) - len(suffix))]}{suffix}"
|
|
10813
10816
|
try:
|
|
10814
10817
|
return self._post_comment_as_pr_token_once(pr_number, body)
|
|
10815
10818
|
except RetryableError as exc:
|
|
@@ -10831,7 +10834,12 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
|
|
10831
10834
|
comment
|
|
10832
10835
|
for comment in comments
|
|
10833
10836
|
if comment.author.casefold() == dispatch_login.casefold()
|
|
10834
|
-
and marker
|
|
10837
|
+
and marker
|
|
10838
|
+
in (
|
|
10839
|
+
comment.body.split("\n\n", 2)[1].splitlines()
|
|
10840
|
+
if initial_delivery and comment.body.startswith("@copilot\n\n")
|
|
10841
|
+
else _agent_retry_comment_header(comment.body)
|
|
10842
|
+
)
|
|
10835
10843
|
]
|
|
10836
10844
|
if matching_comments:
|
|
10837
10845
|
return max(matching_comments, key=lambda comment: comment.id).id
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Repository-backed initial-delivery claims and staged escalation reservations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from agentic_devtools.cli.ci.reconciliation.queue_store import (
|
|
12
|
+
GitHubVariableBackingStore,
|
|
13
|
+
_is_conflict_error,
|
|
14
|
+
_is_not_found_error,
|
|
15
|
+
)
|
|
16
|
+
from agentic_devtools.cli.ci.retry import retry_with_backoff
|
|
17
|
+
from agentic_devtools.cli.shared.retry import RetryableError
|
|
18
|
+
|
|
19
|
+
_ESCALATION_RESERVATION_TTL = timedelta(minutes=15)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InitialDeliveryClaims:
|
|
23
|
+
"""One shared claim per original delivery and purpose.
|
|
24
|
+
|
|
25
|
+
Recovery and delivery claims are immutable. Escalation reservations use the
|
|
26
|
+
GitHub contents blob SHA for owner-checked compare-and-swap stage changes.
|
|
27
|
+
Never delete claims or retry an ambiguous create. The existing
|
|
28
|
+
``ai-pr-loop-state`` branch must already be initialized.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, repo: str) -> None:
|
|
32
|
+
if (
|
|
33
|
+
not isinstance(repo, str)
|
|
34
|
+
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", repo) is None
|
|
35
|
+
):
|
|
36
|
+
raise ValueError("repo must be a canonical owner/name")
|
|
37
|
+
self._repo = repo
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
@retry_with_backoff(max_retries=0)
|
|
41
|
+
def _api(endpoint: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> str:
|
|
42
|
+
"""Use the existing repository-state credential boundary without retries."""
|
|
43
|
+
return GitHubVariableBackingStore._api(endpoint, method=method, body=body)
|
|
44
|
+
|
|
45
|
+
def _endpoint(self, pr: int, key: str, purpose: str) -> str:
|
|
46
|
+
if (
|
|
47
|
+
isinstance(pr, bool)
|
|
48
|
+
or not isinstance(pr, int)
|
|
49
|
+
or pr <= 0
|
|
50
|
+
or not isinstance(key, str)
|
|
51
|
+
or re.fullmatch(r"[0-9a-f]{64}", key) is None
|
|
52
|
+
or purpose not in {"recovery", "escalation", "delivered"}
|
|
53
|
+
):
|
|
54
|
+
raise ValueError("invalid initial-delivery claim identity")
|
|
55
|
+
return f"/repos/{self._repo}/contents/.agdt/initial-delivery/{pr}/{key}/{purpose}.json"
|
|
56
|
+
|
|
57
|
+
def _read_versioned(self, pr: int, key: str, purpose: str) -> tuple[dict[str, Any], str] | None:
|
|
58
|
+
"""Read a claim and its blob SHA for narrowly scoped compare-and-swap updates."""
|
|
59
|
+
try:
|
|
60
|
+
raw = self._api(f"{self._endpoint(pr, key, purpose)}?ref=ai-pr-loop-state")
|
|
61
|
+
except RuntimeError as exc:
|
|
62
|
+
if _is_not_found_error(exc):
|
|
63
|
+
return None
|
|
64
|
+
raise
|
|
65
|
+
metadata = json.loads(raw)
|
|
66
|
+
content = base64.b64decode(metadata["content"].replace("\n", ""), validate=True)
|
|
67
|
+
if len(content) > 16_000:
|
|
68
|
+
raise ValueError("initial-delivery claim exceeds bounded size")
|
|
69
|
+
record = json.loads(content)
|
|
70
|
+
if not isinstance(record, dict) or not isinstance(record.get("created_at"), str):
|
|
71
|
+
raise ValueError("malformed initial-delivery claim")
|
|
72
|
+
now = datetime.now(UTC)
|
|
73
|
+
created = datetime.fromisoformat(record["created_at"])
|
|
74
|
+
if created.tzinfo is None or created > now:
|
|
75
|
+
raise ValueError("invalid initial-delivery claim timestamp")
|
|
76
|
+
if "updated_at" in record:
|
|
77
|
+
updated_at = record["updated_at"]
|
|
78
|
+
if not isinstance(updated_at, str):
|
|
79
|
+
raise ValueError("invalid initial-delivery claim timestamp")
|
|
80
|
+
updated = datetime.fromisoformat(updated_at)
|
|
81
|
+
if updated.tzinfo is None or updated < created or updated > now:
|
|
82
|
+
raise ValueError("invalid initial-delivery claim timestamp")
|
|
83
|
+
sha = metadata.get("sha")
|
|
84
|
+
if not isinstance(sha, str) or not sha:
|
|
85
|
+
raise ValueError("malformed initial-delivery claim")
|
|
86
|
+
return record, sha
|
|
87
|
+
|
|
88
|
+
def read(self, pr: int, key: str, purpose: str) -> dict[str, Any] | None:
|
|
89
|
+
"""Read a claim; malformed/unavailable data is never treated as absence."""
|
|
90
|
+
versioned = self._read_versioned(pr, key, purpose)
|
|
91
|
+
return versioned[0] if versioned is not None else None
|
|
92
|
+
|
|
93
|
+
def claim(self, pr: int, key: str, purpose: str, evidence: dict[str, Any]) -> bool:
|
|
94
|
+
"""Create once before mutation; only a confirmed create authorizes work."""
|
|
95
|
+
endpoint = self._endpoint(pr, key, purpose)
|
|
96
|
+
record = {**evidence, "created_at": datetime.now(UTC).isoformat()}
|
|
97
|
+
content = json.dumps(record).encode()
|
|
98
|
+
if len(content) > 16_000:
|
|
99
|
+
raise ValueError("initial-delivery claim exceeds bounded size")
|
|
100
|
+
try:
|
|
101
|
+
self._api(
|
|
102
|
+
endpoint,
|
|
103
|
+
method="PUT",
|
|
104
|
+
body={
|
|
105
|
+
"branch": "ai-pr-loop-state",
|
|
106
|
+
"message": f"chore(#{pr}): claim initial-delivery {purpose}\n\n#{pr}",
|
|
107
|
+
"content": base64.b64encode(content).decode("ascii"),
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
except RuntimeError, RetryableError:
|
|
111
|
+
if self.read(pr, key, purpose) is not None:
|
|
112
|
+
return False
|
|
113
|
+
raise
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def _reserve_escalation(self, pr: int, key: str, evidence: dict[str, Any], owner_token: str) -> bool:
|
|
117
|
+
"""Create or CAS-acquire one owner's escalation reservation."""
|
|
118
|
+
if not isinstance(owner_token, str) or re.fullmatch(r"[0-9a-f]{32}", owner_token) is None:
|
|
119
|
+
raise ValueError("invalid escalation reservation owner")
|
|
120
|
+
reservation = {**evidence, "stage": "reserved", "owner_token": owner_token}
|
|
121
|
+
if self.claim(pr, key, "escalation", reservation):
|
|
122
|
+
return True
|
|
123
|
+
existing = self._read_versioned(pr, key, "escalation")
|
|
124
|
+
if existing is None:
|
|
125
|
+
return False
|
|
126
|
+
record, _sha = existing
|
|
127
|
+
if record.get("stage") == "reserved" and record.get("owner_token") == owner_token:
|
|
128
|
+
return True
|
|
129
|
+
if record.get("stage") == "reserved":
|
|
130
|
+
acquired = datetime.fromisoformat(record.get("updated_at", record["created_at"]))
|
|
131
|
+
if datetime.now(UTC) - acquired < _ESCALATION_RESERVATION_TTL:
|
|
132
|
+
return False
|
|
133
|
+
current_owner = record.get("owner_token")
|
|
134
|
+
if not isinstance(current_owner, str) or re.fullmatch(r"[0-9a-f]{32}", current_owner) is None:
|
|
135
|
+
return False
|
|
136
|
+
return self._transition_escalation(
|
|
137
|
+
pr,
|
|
138
|
+
key,
|
|
139
|
+
current_owner,
|
|
140
|
+
"reserved",
|
|
141
|
+
"reserved",
|
|
142
|
+
next_owner_token=owner_token,
|
|
143
|
+
)
|
|
144
|
+
if record.get("stage") != "retryable":
|
|
145
|
+
return False
|
|
146
|
+
current_owner = record.get("owner_token")
|
|
147
|
+
if not isinstance(current_owner, str) or re.fullmatch(r"[0-9a-f]{32}", current_owner) is None:
|
|
148
|
+
return False
|
|
149
|
+
return self._transition_escalation(
|
|
150
|
+
pr,
|
|
151
|
+
key,
|
|
152
|
+
current_owner,
|
|
153
|
+
"retryable",
|
|
154
|
+
"reserved",
|
|
155
|
+
next_owner_token=owner_token,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _transition_escalation(
|
|
159
|
+
self,
|
|
160
|
+
pr: int,
|
|
161
|
+
key: str,
|
|
162
|
+
owner_token: str,
|
|
163
|
+
expected_stage: str,
|
|
164
|
+
next_stage: str,
|
|
165
|
+
*,
|
|
166
|
+
next_owner_token: str | None = None,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""CAS an escalation stage; ambiguous writes require exact read-back verification."""
|
|
169
|
+
allowed = {
|
|
170
|
+
("reserved", "retryable"),
|
|
171
|
+
("reserved", "reserved"),
|
|
172
|
+
("retryable", "reserved"),
|
|
173
|
+
("reserved", "attempted"),
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
(expected_stage, next_stage) not in allowed
|
|
177
|
+
or not isinstance(owner_token, str)
|
|
178
|
+
or re.fullmatch(r"[0-9a-f]{32}", owner_token) is None
|
|
179
|
+
or (
|
|
180
|
+
next_owner_token is not None
|
|
181
|
+
and (not isinstance(next_owner_token, str) or re.fullmatch(r"[0-9a-f]{32}", next_owner_token) is None)
|
|
182
|
+
)
|
|
183
|
+
or (
|
|
184
|
+
(expected_stage, next_stage) in {("retryable", "reserved"), ("reserved", "reserved")}
|
|
185
|
+
and next_owner_token is None
|
|
186
|
+
)
|
|
187
|
+
or (
|
|
188
|
+
(expected_stage, next_stage) not in {("retryable", "reserved"), ("reserved", "reserved")}
|
|
189
|
+
and next_owner_token is not None
|
|
190
|
+
)
|
|
191
|
+
):
|
|
192
|
+
raise ValueError("invalid escalation reservation transition")
|
|
193
|
+
versioned = self._read_versioned(pr, key, "escalation")
|
|
194
|
+
if versioned is None:
|
|
195
|
+
return False
|
|
196
|
+
record, sha = versioned
|
|
197
|
+
if record.get("stage") != expected_stage or record.get("owner_token") != owner_token:
|
|
198
|
+
return False
|
|
199
|
+
updated = dict(record)
|
|
200
|
+
updated["stage"] = next_stage
|
|
201
|
+
if next_owner_token is not None:
|
|
202
|
+
updated["owner_token"] = next_owner_token
|
|
203
|
+
updated["updated_at"] = datetime.now(UTC).isoformat()
|
|
204
|
+
content = json.dumps(updated).encode()
|
|
205
|
+
if len(content) > 16_000:
|
|
206
|
+
raise ValueError("initial-delivery claim exceeds bounded size")
|
|
207
|
+
try:
|
|
208
|
+
self._api(
|
|
209
|
+
self._endpoint(pr, key, "escalation"),
|
|
210
|
+
method="PUT",
|
|
211
|
+
body={
|
|
212
|
+
"branch": "ai-pr-loop-state",
|
|
213
|
+
"message": f"chore(#{pr}): advance initial-delivery escalation\n\n#{pr}",
|
|
214
|
+
"content": base64.b64encode(content).decode("ascii"),
|
|
215
|
+
"sha": sha,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
except (RuntimeError, RetryableError) as exc:
|
|
219
|
+
if _is_conflict_error(cast(RuntimeError, exc)):
|
|
220
|
+
return False
|
|
221
|
+
observed = self._read_versioned(pr, key, "escalation")
|
|
222
|
+
if observed is not None and observed[0] == updated:
|
|
223
|
+
return True
|
|
224
|
+
if observed is not None and observed[0] == record:
|
|
225
|
+
return False
|
|
226
|
+
raise
|
|
227
|
+
return True
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Bounded recovery consumer for independently admitted initial-delivery evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from agentic_devtools.cli.ci.guards import AGENT_RETRY_CONFIRMATION_WINDOW
|
|
12
|
+
from agentic_devtools.cli.ci.initial_delivery_claims import InitialDeliveryClaims
|
|
13
|
+
from agentic_devtools.cli.ci.models import AgentTaskInventoryAvailability
|
|
14
|
+
from agentic_devtools.cli.ci.pipeline.delivery import (
|
|
15
|
+
InitialDeliveryEvidence,
|
|
16
|
+
InitialDeliveryOutcome,
|
|
17
|
+
classify_initial_delivery_with_head,
|
|
18
|
+
)
|
|
19
|
+
from agentic_devtools.cli.ci.pipeline.models import ActionDecision, ActionResult
|
|
20
|
+
from agentic_devtools.cli.ci.pipeline.session_detector import (
|
|
21
|
+
SessionOutcome,
|
|
22
|
+
SessionState,
|
|
23
|
+
get_latest_session_state,
|
|
24
|
+
is_copilot_session_active_via_agent_task,
|
|
25
|
+
)
|
|
26
|
+
from agentic_devtools.cli.ci.pipeline.snapshot_models import DerivedState, PRStateSnapshot
|
|
27
|
+
from agentic_devtools.cli.ci.pipeline.snapshot_utils import _raise_if_rate_limit
|
|
28
|
+
from agentic_devtools.cli.ci.provider import CIPlatformProvider
|
|
29
|
+
from agentic_devtools.cli.shared.retry import ProviderRateLimitError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from agentic_devtools.cli.ci.pipeline.actions.retry_failed_session import RetryFailedSessionAction
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InitialDeliveryRecovery:
|
|
36
|
+
"""Consume one original-delivery claim, never a terminal-event or per-SHA cap."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, action: RetryFailedSessionAction) -> None:
|
|
39
|
+
self._action = action
|
|
40
|
+
self._provider = action._provider
|
|
41
|
+
|
|
42
|
+
def _result(self, decision: ActionDecision, details: str) -> ActionResult:
|
|
43
|
+
return ActionResult(name=self._action.name, decision=decision, details=details)
|
|
44
|
+
|
|
45
|
+
def evaluate_session(
|
|
46
|
+
self, snapshot: PRStateSnapshot, derived: DerivedState, state: SessionState
|
|
47
|
+
) -> ActionResult | None:
|
|
48
|
+
"""Route terminal admission without changing the existing session guards."""
|
|
49
|
+
if state.outcome is SessionOutcome.UNKNOWN:
|
|
50
|
+
return ActionResult(
|
|
51
|
+
name=self._action.name,
|
|
52
|
+
decision=ActionDecision.BLOCKED,
|
|
53
|
+
preconditions={"session_outcome_known": False},
|
|
54
|
+
details="Latest Copilot session outcome is unknown",
|
|
55
|
+
)
|
|
56
|
+
if state.outcome is SessionOutcome.ACTIVE:
|
|
57
|
+
return ActionResult(
|
|
58
|
+
name=self._action.name,
|
|
59
|
+
decision=ActionDecision.BLOCKED,
|
|
60
|
+
preconditions={"session_active": True},
|
|
61
|
+
details="Active Copilot session blocks retry and subsequent pipeline actions",
|
|
62
|
+
)
|
|
63
|
+
if snapshot.initial_delivery is not None:
|
|
64
|
+
result = self.evaluate(snapshot, derived, state)
|
|
65
|
+
if result is not None:
|
|
66
|
+
return result
|
|
67
|
+
if state.outcome is not SessionOutcome.FAILED:
|
|
68
|
+
return ActionResult(
|
|
69
|
+
name=self._action.name,
|
|
70
|
+
decision=ActionDecision.SKIP,
|
|
71
|
+
preconditions={"session_failed": False},
|
|
72
|
+
details=f"Latest Copilot session outcome is {state.outcome.value}",
|
|
73
|
+
)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _identity(evidence: object) -> str:
|
|
78
|
+
if (
|
|
79
|
+
not isinstance(evidence, InitialDeliveryEvidence)
|
|
80
|
+
or not isinstance(evidence.outcome, InitialDeliveryOutcome)
|
|
81
|
+
or not isinstance(evidence.initial_task_id, str)
|
|
82
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{1,200}", evidence.initial_task_id) is None
|
|
83
|
+
or not isinstance(evidence.initial_plan_sha, str)
|
|
84
|
+
or re.fullmatch(r"[0-9a-f]{40}", evidence.initial_plan_sha) is None
|
|
85
|
+
or not isinstance(evidence.head_sha, str)
|
|
86
|
+
or re.fullmatch(r"[0-9a-f]{40}", evidence.head_sha) is None
|
|
87
|
+
):
|
|
88
|
+
return ""
|
|
89
|
+
return hashlib.sha256(f"{evidence.initial_task_id}\n{evidence.initial_plan_sha}".encode()).hexdigest()
|
|
90
|
+
|
|
91
|
+
def _observe(self, snapshot: PRStateSnapshot, state: SessionState) -> InitialDeliveryOutcome | None:
|
|
92
|
+
evidence = snapshot.initial_delivery
|
|
93
|
+
if (
|
|
94
|
+
not self._identity(evidence)
|
|
95
|
+
or evidence is None
|
|
96
|
+
or evidence.head_sha != snapshot.head_sha
|
|
97
|
+
or state.inventory_availability is not AgentTaskInventoryAvailability.COMPLETE
|
|
98
|
+
or state.outcome not in {SessionOutcome.FINISHED, SessionOutcome.FAILED}
|
|
99
|
+
or not isinstance(state.task_id, str)
|
|
100
|
+
or not state.task_id
|
|
101
|
+
):
|
|
102
|
+
return None
|
|
103
|
+
live = self._provider.get_pr_metadata(snapshot.pr_number)
|
|
104
|
+
if (
|
|
105
|
+
live.head_sha != snapshot.head_sha
|
|
106
|
+
or live.head_branch != snapshot.head_branch
|
|
107
|
+
or live.head_repo_full_name != snapshot.head_repo_full_name
|
|
108
|
+
or live.base_repo_full_name != snapshot.base_repo_full_name
|
|
109
|
+
or live.labels != snapshot.labels
|
|
110
|
+
or self._has_active_session(snapshot)
|
|
111
|
+
):
|
|
112
|
+
return None
|
|
113
|
+
observed_head, outcome = classify_initial_delivery_with_head(
|
|
114
|
+
self._provider,
|
|
115
|
+
snapshot.pr_number,
|
|
116
|
+
completion_confirmed=state.outcome is SessionOutcome.FINISHED,
|
|
117
|
+
initial_plan_sha=evidence.initial_plan_sha,
|
|
118
|
+
)
|
|
119
|
+
if observed_head and observed_head != snapshot.head_sha:
|
|
120
|
+
return None
|
|
121
|
+
if self._provider.get_pr_metadata(snapshot.pr_number) != live:
|
|
122
|
+
return None
|
|
123
|
+
return outcome
|
|
124
|
+
|
|
125
|
+
def _has_active_session(self, snapshot: PRStateSnapshot) -> bool:
|
|
126
|
+
"""Invalidate cached lifecycle and writer inventories before every safety check."""
|
|
127
|
+
self._provider.invalidate_agent_task_inventory(snapshot.pr_number)
|
|
128
|
+
return (
|
|
129
|
+
is_copilot_session_active_via_agent_task(
|
|
130
|
+
snapshot.base_repo_full_name,
|
|
131
|
+
snapshot.pr_number,
|
|
132
|
+
provider=self._provider,
|
|
133
|
+
head_branch=snapshot.head_branch,
|
|
134
|
+
pr_id=snapshot.pr_id,
|
|
135
|
+
head_sha=snapshot.head_sha,
|
|
136
|
+
)
|
|
137
|
+
is not False
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _matches_delivered_handoff(
|
|
142
|
+
claim: object,
|
|
143
|
+
evidence: InitialDeliveryEvidence,
|
|
144
|
+
) -> bool:
|
|
145
|
+
"""Match a persisted completed recovery to its exact source, task and head."""
|
|
146
|
+
return (
|
|
147
|
+
isinstance(claim, dict)
|
|
148
|
+
and claim.get("initial_task_id") == evidence.initial_task_id
|
|
149
|
+
and claim.get("initial_plan_sha") == evidence.initial_plan_sha
|
|
150
|
+
and claim.get("head") == evidence.head_sha
|
|
151
|
+
and isinstance(claim.get("task_id"), str)
|
|
152
|
+
and re.fullmatch(r"[A-Za-z0-9_-]{1,200}", claim["task_id"]) is not None
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _delivered_receipt_key(key: str, evidence: InitialDeliveryEvidence, task_id: str | None = None) -> str:
|
|
157
|
+
"""Index immutable delivery receipts by admission, head, and optional task."""
|
|
158
|
+
return hashlib.sha256("\n".join(filter(None, (key, evidence.head_sha, task_id))).encode()).hexdigest()
|
|
159
|
+
|
|
160
|
+
def _record_delivered_handoff(
|
|
161
|
+
self,
|
|
162
|
+
store: InitialDeliveryClaims,
|
|
163
|
+
snapshot: PRStateSnapshot,
|
|
164
|
+
key: str,
|
|
165
|
+
evidence: InitialDeliveryEvidence,
|
|
166
|
+
task_id: str,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Persist the exact verified task/head that completed initial-delivery recovery."""
|
|
169
|
+
record = {
|
|
170
|
+
"head": evidence.head_sha,
|
|
171
|
+
"initial_task_id": evidence.initial_task_id,
|
|
172
|
+
"initial_plan_sha": evidence.initial_plan_sha,
|
|
173
|
+
"task_id": task_id,
|
|
174
|
+
}
|
|
175
|
+
receipt_key = self._delivered_receipt_key(key, evidence)
|
|
176
|
+
if store.claim(snapshot.pr_number, receipt_key, "delivered", record):
|
|
177
|
+
return True
|
|
178
|
+
existing = store.read(snapshot.pr_number, receipt_key, "delivered")
|
|
179
|
+
if (
|
|
180
|
+
existing is None
|
|
181
|
+
or not self._matches_delivered_handoff(existing, evidence)
|
|
182
|
+
or existing.get("head") != snapshot.head_sha
|
|
183
|
+
):
|
|
184
|
+
return False
|
|
185
|
+
if existing.get("task_id") == task_id:
|
|
186
|
+
return True
|
|
187
|
+
task_receipt_key = self._delivered_receipt_key(key, evidence, task_id)
|
|
188
|
+
if store.claim(snapshot.pr_number, task_receipt_key, "delivered", record):
|
|
189
|
+
return True
|
|
190
|
+
task_receipt = store.read(snapshot.pr_number, task_receipt_key, "delivered")
|
|
191
|
+
return (
|
|
192
|
+
task_receipt is not None
|
|
193
|
+
and self._matches_delivered_handoff(task_receipt, evidence)
|
|
194
|
+
and task_receipt.get("head") == snapshot.head_sha
|
|
195
|
+
and task_receipt.get("task_id") == task_id
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _should_defer_failed_task(
|
|
199
|
+
self,
|
|
200
|
+
store: InitialDeliveryClaims,
|
|
201
|
+
claim: dict | None,
|
|
202
|
+
snapshot: PRStateSnapshot,
|
|
203
|
+
key: str,
|
|
204
|
+
state: SessionState,
|
|
205
|
+
) -> bool | None:
|
|
206
|
+
"""Route unrelated failures normally and fence tasks already recorded delivered."""
|
|
207
|
+
if state.outcome is not SessionOutcome.FAILED:
|
|
208
|
+
return False
|
|
209
|
+
if claim is None:
|
|
210
|
+
return True
|
|
211
|
+
evidence = snapshot.initial_delivery
|
|
212
|
+
if (
|
|
213
|
+
not isinstance(state.task_id, str)
|
|
214
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{1,200}", state.task_id) is None
|
|
215
|
+
or evidence is None
|
|
216
|
+
):
|
|
217
|
+
return None
|
|
218
|
+
receipt_key = self._delivered_receipt_key(key, evidence)
|
|
219
|
+
delivered = store.read(snapshot.pr_number, receipt_key, "delivered")
|
|
220
|
+
if (
|
|
221
|
+
delivered is None
|
|
222
|
+
or not self._matches_delivered_handoff(delivered, evidence)
|
|
223
|
+
or delivered.get("head") != snapshot.head_sha
|
|
224
|
+
):
|
|
225
|
+
return False
|
|
226
|
+
if state.task_id == delivered.get("task_id"):
|
|
227
|
+
return None
|
|
228
|
+
task_receipt_key = self._delivered_receipt_key(key, evidence, state.task_id)
|
|
229
|
+
task_receipt = store.read(snapshot.pr_number, task_receipt_key, "delivered")
|
|
230
|
+
if (
|
|
231
|
+
task_receipt is not None
|
|
232
|
+
and self._matches_delivered_handoff(task_receipt, evidence)
|
|
233
|
+
and task_receipt.get("head") == snapshot.head_sha
|
|
234
|
+
and task_receipt.get("task_id") == state.task_id
|
|
235
|
+
):
|
|
236
|
+
return None
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
def _handle_unknown_failed_task(
|
|
240
|
+
self,
|
|
241
|
+
store: InitialDeliveryClaims,
|
|
242
|
+
claim: dict | None,
|
|
243
|
+
snapshot: PRStateSnapshot,
|
|
244
|
+
key: str,
|
|
245
|
+
state: SessionState,
|
|
246
|
+
outcome: InitialDeliveryOutcome | None,
|
|
247
|
+
) -> tuple[bool, ActionResult | None]:
|
|
248
|
+
defer = (
|
|
249
|
+
self._should_defer_failed_task(store, claim, snapshot, key, state)
|
|
250
|
+
if outcome is InitialDeliveryOutcome.UNKNOWN
|
|
251
|
+
else False
|
|
252
|
+
)
|
|
253
|
+
return defer is not False, self._result(
|
|
254
|
+
ActionDecision.BLOCKED, "A previously delivered task cannot re-enter recovery"
|
|
255
|
+
) if defer is None else None
|
|
256
|
+
|
|
257
|
+
def _delivered_result(
|
|
258
|
+
self,
|
|
259
|
+
store: InitialDeliveryClaims,
|
|
260
|
+
claim: dict | None,
|
|
261
|
+
snapshot: PRStateSnapshot,
|
|
262
|
+
key: str,
|
|
263
|
+
evidence: InitialDeliveryEvidence | None,
|
|
264
|
+
state: SessionState,
|
|
265
|
+
) -> ActionResult:
|
|
266
|
+
"""Persist claimed recovery completion before returning to normal pipeline gates."""
|
|
267
|
+
if state.outcome is not SessionOutcome.FINISHED:
|
|
268
|
+
return self._result(ActionDecision.BLOCKED, "Delivery requires a completed task")
|
|
269
|
+
if claim is not None and (
|
|
270
|
+
evidence is None
|
|
271
|
+
or not isinstance(state.task_id, str)
|
|
272
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{1,200}", state.task_id) is None
|
|
273
|
+
or not self._record_delivered_handoff(store, snapshot, key, evidence, state.task_id)
|
|
274
|
+
):
|
|
275
|
+
return self._result(ActionDecision.BLOCKED, "Verified recovery handoff could not be persisted")
|
|
276
|
+
return self._result(ActionDecision.SKIP, "Fresh complete non-empty delivery returns to normal PR gates")
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _mode(claim: dict | None, state: SessionState, evidence: InitialDeliveryEvidence) -> str:
|
|
280
|
+
if claim is None:
|
|
281
|
+
return (
|
|
282
|
+
"retry"
|
|
283
|
+
if state.outcome is SessionOutcome.FINISHED and state.task_id == evidence.initial_task_id
|
|
284
|
+
else "invalid"
|
|
285
|
+
)
|
|
286
|
+
if (
|
|
287
|
+
state.task_id == evidence.initial_task_id
|
|
288
|
+
and datetime.now(UTC) - datetime.fromisoformat(claim["created_at"]) < AGENT_RETRY_CONFIRMATION_WINDOW
|
|
289
|
+
):
|
|
290
|
+
return "wait"
|
|
291
|
+
return "escalate"
|
|
292
|
+
|
|
293
|
+
def evaluate(self, snapshot: PRStateSnapshot, derived: DerivedState, state: SessionState) -> ActionResult | None:
|
|
294
|
+
"""Fail closed without admission; preserve ordinary true-FAILED handling."""
|
|
295
|
+
try:
|
|
296
|
+
key = self._identity(snapshot.initial_delivery)
|
|
297
|
+
if not key:
|
|
298
|
+
return self._result(ActionDecision.BLOCKED, "Initial-delivery admission is malformed")
|
|
299
|
+
store = self._provider._get_initial_delivery_claims()
|
|
300
|
+
claim = store.read(snapshot.pr_number, key, "recovery")
|
|
301
|
+
evidence = snapshot.initial_delivery
|
|
302
|
+
if claim is None and state.outcome is SessionOutcome.FAILED:
|
|
303
|
+
return None
|
|
304
|
+
outcome = self._observe(snapshot, state)
|
|
305
|
+
if outcome is None or (outcome is InitialDeliveryOutcome.UNKNOWN and claim is None):
|
|
306
|
+
return self._result(ActionDecision.BLOCKED, "Initial-delivery evidence is unknown, active or stale")
|
|
307
|
+
handled, failed_task_result = self._handle_unknown_failed_task(store, claim, snapshot, key, state, outcome)
|
|
308
|
+
if handled:
|
|
309
|
+
return failed_task_result
|
|
310
|
+
if outcome is InitialDeliveryOutcome.DELIVERED:
|
|
311
|
+
return self._delivered_result(store, claim, snapshot, key, evidence, state)
|
|
312
|
+
evidence = snapshot.initial_delivery
|
|
313
|
+
assert evidence is not None
|
|
314
|
+
if claim is None and evidence.outcome is not InitialDeliveryOutcome.NO_DELIVERY:
|
|
315
|
+
return self._result(ActionDecision.BLOCKED, "Recovery requires typed NO_DELIVERY admission")
|
|
316
|
+
mode = self._mode(claim, state, evidence)
|
|
317
|
+
if mode in {"wait", "invalid"}:
|
|
318
|
+
return self._result(
|
|
319
|
+
ActionDecision.BLOCKED, "Recovery claimed or original task identity changed; waiting"
|
|
320
|
+
)
|
|
321
|
+
derived.set("initial_delivery_mode", mode)
|
|
322
|
+
derived.set("initial_delivery_key", key)
|
|
323
|
+
derived.set("initial_delivery_session", state)
|
|
324
|
+
return self._result(ActionDecision.EXECUTE, f"Initial-delivery recovery: {mode}")
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
if isinstance(exc, ProviderRateLimitError) and exc.is_rate_limit:
|
|
327
|
+
raise
|
|
328
|
+
return self._result(ActionDecision.BLOCKED, "Initial-delivery evidence or durable claims unavailable")
|
|
329
|
+
|
|
330
|
+
def execute(self, provider: CIPlatformProvider, snapshot: PRStateSnapshot, derived: DerivedState) -> ActionResult:
|
|
331
|
+
"""Revalidate before each mutation; a lost response never releases a claim."""
|
|
332
|
+
try:
|
|
333
|
+
state = get_latest_session_state(
|
|
334
|
+
provider,
|
|
335
|
+
snapshot.pr_number,
|
|
336
|
+
repo=snapshot.base_repo_full_name,
|
|
337
|
+
head_branch=snapshot.head_branch,
|
|
338
|
+
pr_id=snapshot.pr_id,
|
|
339
|
+
head_sha=snapshot.head_sha,
|
|
340
|
+
)
|
|
341
|
+
outcome = self._observe(snapshot, state)
|
|
342
|
+
if (
|
|
343
|
+
state != derived.get("initial_delivery_session")
|
|
344
|
+
or outcome is None
|
|
345
|
+
or outcome is InitialDeliveryOutcome.DELIVERED
|
|
346
|
+
):
|
|
347
|
+
return self._result(ActionDecision.BLOCKED, "Initial-delivery head, diff or lifecycle changed")
|
|
348
|
+
if derived.get("initial_delivery_mode") == "escalate":
|
|
349
|
+
return self._escalate(provider, snapshot, derived, "One-attempt recovery budget exhausted")
|
|
350
|
+
if outcome is not InitialDeliveryOutcome.NO_DELIVERY:
|
|
351
|
+
return self._result(ActionDecision.BLOCKED, "Initial-delivery classification became unknown")
|
|
352
|
+
key = derived.get("initial_delivery_key")
|
|
353
|
+
evidence = snapshot.initial_delivery
|
|
354
|
+
assert evidence is not None
|
|
355
|
+
# Build context before claiming so failures here cannot consume a dispatch.
|
|
356
|
+
derived.set("agent_retry_task_id", evidence.initial_task_id)
|
|
357
|
+
prompt = self._action._build_retry_prompt(snapshot, derived, key, 1, 1)
|
|
358
|
+
prompt = prompt.replace("agdt:agent-retry:", "agdt:initial-delivery-retry:")
|
|
359
|
+
prompt = prompt.replace(
|
|
360
|
+
f"Copilot session {key} failed.",
|
|
361
|
+
"The initial implementation completed with verified NO_DELIVERY.",
|
|
362
|
+
)
|
|
363
|
+
store = provider._get_initial_delivery_claims()
|
|
364
|
+
if not store.claim(
|
|
365
|
+
snapshot.pr_number,
|
|
366
|
+
key,
|
|
367
|
+
"recovery",
|
|
368
|
+
{
|
|
369
|
+
"head": snapshot.head_sha,
|
|
370
|
+
"initial_task_id": evidence.initial_task_id,
|
|
371
|
+
"diff": "ZERO_DIFF",
|
|
372
|
+
},
|
|
373
|
+
):
|
|
374
|
+
return self._result(ActionDecision.BLOCKED, "Another worker already consumed the recovery claim")
|
|
375
|
+
# Check again after the durable write, immediately before the non-idempotent call.
|
|
376
|
+
latest = get_latest_session_state(
|
|
377
|
+
provider,
|
|
378
|
+
snapshot.pr_number,
|
|
379
|
+
repo=snapshot.base_repo_full_name,
|
|
380
|
+
head_branch=snapshot.head_branch,
|
|
381
|
+
pr_id=snapshot.pr_id,
|
|
382
|
+
head_sha=snapshot.head_sha,
|
|
383
|
+
)
|
|
384
|
+
if latest != state or self._observe(snapshot, latest) is not InitialDeliveryOutcome.NO_DELIVERY:
|
|
385
|
+
return self._result(ActionDecision.BLOCKED, "Recovery claim retained; evidence changed before dispatch")
|
|
386
|
+
try:
|
|
387
|
+
receipt = provider._dispatch_agent_retry(snapshot.pr_number, prompt, key, initial_delivery=True)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
_raise_if_rate_limit(exc)
|
|
390
|
+
return self._escalate(
|
|
391
|
+
provider, snapshot, derived, "Recovery dispatch failed or acceptance is uncertain"
|
|
392
|
+
)
|
|
393
|
+
if not isinstance(receipt, int) or isinstance(receipt, bool) or receipt <= 0:
|
|
394
|
+
return self._escalate(provider, snapshot, derived, "Recovery dispatch acceptance is uncertain")
|
|
395
|
+
return ActionResult(
|
|
396
|
+
name=self._action.name,
|
|
397
|
+
decision=ActionDecision.EXECUTE,
|
|
398
|
+
preconditions={"retry_dispatched": True},
|
|
399
|
+
details="Dispatched the single initial-delivery recovery",
|
|
400
|
+
)
|
|
401
|
+
except Exception as exc:
|
|
402
|
+
if isinstance(exc, ProviderRateLimitError) and exc.is_rate_limit:
|
|
403
|
+
raise
|
|
404
|
+
return self._result(
|
|
405
|
+
ActionDecision.FAILED, "Recovery stopped; durable claim/evidence unavailable, no resend"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def validate_escalation(self, snapshot: PRStateSnapshot, derived: DerivedState) -> None:
|
|
409
|
+
"""Refuse initial-delivery notifications or exclusion after a lifecycle change."""
|
|
410
|
+
if not self._escalation_is_safe(snapshot, derived):
|
|
411
|
+
raise RuntimeError("Recovery lifecycle or delivery changed; escalation stopped without exclusion")
|
|
412
|
+
|
|
413
|
+
def _escalation_is_safe(self, snapshot: PRStateSnapshot, derived: DerivedState) -> bool:
|
|
414
|
+
"""Return whether the current delivery and task lifecycle still allow escalation."""
|
|
415
|
+
if not derived.get("initial_delivery_mode"):
|
|
416
|
+
return True
|
|
417
|
+
state = get_latest_session_state(
|
|
418
|
+
self._provider,
|
|
419
|
+
snapshot.pr_number,
|
|
420
|
+
repo=snapshot.base_repo_full_name,
|
|
421
|
+
head_branch=snapshot.head_branch,
|
|
422
|
+
pr_id=snapshot.pr_id,
|
|
423
|
+
head_sha=snapshot.head_sha,
|
|
424
|
+
)
|
|
425
|
+
outcome = self._observe(snapshot, state)
|
|
426
|
+
return outcome is not None and outcome is not InitialDeliveryOutcome.DELIVERED
|
|
427
|
+
|
|
428
|
+
def _escalate(
|
|
429
|
+
self, provider: CIPlatformProvider, snapshot: PRStateSnapshot, derived: DerivedState, reason: str
|
|
430
|
+
) -> ActionResult:
|
|
431
|
+
"""Serialize notification attempts too; retain ambiguous notices for manual inspection."""
|
|
432
|
+
evidence = snapshot.initial_delivery
|
|
433
|
+
assert evidence is not None
|
|
434
|
+
state = get_latest_session_state(
|
|
435
|
+
provider,
|
|
436
|
+
snapshot.pr_number,
|
|
437
|
+
repo=snapshot.base_repo_full_name,
|
|
438
|
+
head_branch=snapshot.head_branch,
|
|
439
|
+
pr_id=snapshot.pr_id,
|
|
440
|
+
head_sha=snapshot.head_sha,
|
|
441
|
+
)
|
|
442
|
+
outcome = self._observe(snapshot, state)
|
|
443
|
+
if outcome is None or outcome is InitialDeliveryOutcome.DELIVERED:
|
|
444
|
+
return self._result(ActionDecision.BLOCKED, "Recovery may be active; retain claim without escalation")
|
|
445
|
+
key = derived.get("initial_delivery_key")
|
|
446
|
+
comments = provider.list_issue_comments(snapshot.pr_number)
|
|
447
|
+
login = provider.get_dispatch_login()
|
|
448
|
+
if not isinstance(login, str) or not login:
|
|
449
|
+
return self._result(ActionDecision.FAILED, "Escalation identity unavailable; recovery claim retained")
|
|
450
|
+
diagnostics = evidence.push_diagnostics[:2000] if isinstance(evidence.push_diagnostics, str) else ""
|
|
451
|
+
diagnostics = re.sub(r"@copilot", "Copilot", diagnostics, flags=re.IGNORECASE)
|
|
452
|
+
diff = "ZERO_DIFF" if outcome is InitialDeliveryOutcome.NO_DELIVERY else "UNKNOWN"
|
|
453
|
+
body = (
|
|
454
|
+
f"{reason}. Original task: `{evidence.initial_task_id}`. "
|
|
455
|
+
f"Observed head: `{snapshot.head_sha}`; diff: `{diff}`; "
|
|
456
|
+
"initial-delivery recovery budget: one (consumed). "
|
|
457
|
+
f"Push cause: {'available evidence below' if diagnostics else 'unknown'}.\n\n"
|
|
458
|
+
f"{diagnostics}\n\n"
|
|
459
|
+
"Inspect the original task and any local unpushed work, then recover manually. "
|
|
460
|
+
"Removing `ai-pr-loop:agent-failed` does not reset this durable recovery budget."
|
|
461
|
+
)
|
|
462
|
+
# A prior generic failure notice does not contain this delivery's head/diff evidence.
|
|
463
|
+
comments = [comment for comment in comments if key in self._action._escalation_identities([comment], login)]
|
|
464
|
+
derived.set("agent_retry_comments", comments)
|
|
465
|
+
derived.set("agent_retry_dispatch_login", login)
|
|
466
|
+
derived.set(
|
|
467
|
+
"initial_delivery_escalation_body",
|
|
468
|
+
f"<!-- agdt:agent-retry-escalation:{key} -->\n\n{body}",
|
|
469
|
+
)
|
|
470
|
+
if not comments:
|
|
471
|
+
if not self._escalation_is_safe(snapshot, derived):
|
|
472
|
+
return self._result(ActionDecision.BLOCKED, "Recovery may be active; do not reserve escalation")
|
|
473
|
+
claims = provider._get_initial_delivery_claims()
|
|
474
|
+
owner_token = uuid4().hex
|
|
475
|
+
if not claims._reserve_escalation(
|
|
476
|
+
snapshot.pr_number,
|
|
477
|
+
key,
|
|
478
|
+
{"head": snapshot.head_sha, "diff": diff, "notice": body},
|
|
479
|
+
owner_token,
|
|
480
|
+
):
|
|
481
|
+
return self._result(
|
|
482
|
+
ActionDecision.FAILED,
|
|
483
|
+
"Escalation already attempted; inspect durable escalation evidence, no resend",
|
|
484
|
+
)
|
|
485
|
+
if not self._escalation_is_safe(snapshot, derived):
|
|
486
|
+
if claims._transition_escalation(snapshot.pr_number, key, owner_token, "reserved", "retryable"):
|
|
487
|
+
return self._result(
|
|
488
|
+
ActionDecision.BLOCKED,
|
|
489
|
+
"Recovery became active before notice send; reservation released for safe retry",
|
|
490
|
+
)
|
|
491
|
+
return self._result(
|
|
492
|
+
ActionDecision.FAILED,
|
|
493
|
+
"Pre-send abort could not verify escalation reservation ownership; no notice sent",
|
|
494
|
+
)
|
|
495
|
+
if not claims._transition_escalation(snapshot.pr_number, key, owner_token, "reserved", "attempted"):
|
|
496
|
+
return self._result(
|
|
497
|
+
ActionDecision.FAILED,
|
|
498
|
+
"Escalation send attempt could not be durably fenced; no notice sent",
|
|
499
|
+
)
|
|
500
|
+
return self._action._escalate(provider, snapshot, derived, key)
|
|
@@ -21,6 +21,7 @@ from agentic_devtools.cli.ci.guards import (
|
|
|
21
21
|
is_agent_retry_confirmation_expired,
|
|
22
22
|
)
|
|
23
23
|
from agentic_devtools.cli.ci.models import IssueCommentInfo
|
|
24
|
+
from agentic_devtools.cli.ci.pipeline.actions.initial_delivery_recovery import InitialDeliveryRecovery
|
|
24
25
|
from agentic_devtools.cli.ci.pipeline.models import ActionDecision, ActionResult
|
|
25
26
|
from agentic_devtools.cli.ci.pipeline.session_detector import (
|
|
26
27
|
SessionOutcome,
|
|
@@ -95,27 +96,9 @@ class RetryFailedSessionAction:
|
|
|
95
96
|
),
|
|
96
97
|
)
|
|
97
98
|
derived.set("latest_session_state", state)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
decision=ActionDecision.BLOCKED,
|
|
102
|
-
preconditions={"session_outcome_known": False},
|
|
103
|
-
details="Latest Copilot session outcome is unknown",
|
|
104
|
-
)
|
|
105
|
-
if state.outcome is SessionOutcome.ACTIVE:
|
|
106
|
-
return ActionResult(
|
|
107
|
-
name=self.name,
|
|
108
|
-
decision=ActionDecision.BLOCKED,
|
|
109
|
-
preconditions={"session_active": True},
|
|
110
|
-
details="Active Copilot session blocks retry and subsequent pipeline actions",
|
|
111
|
-
)
|
|
112
|
-
if state.outcome is not SessionOutcome.FAILED:
|
|
113
|
-
return ActionResult(
|
|
114
|
-
name=self.name,
|
|
115
|
-
decision=ActionDecision.SKIP,
|
|
116
|
-
preconditions={"session_failed": False},
|
|
117
|
-
details=f"Latest Copilot session outcome is {state.outcome.value}",
|
|
118
|
-
)
|
|
99
|
+
result = InitialDeliveryRecovery(self).evaluate_session(snapshot, derived, state)
|
|
100
|
+
if result is not None:
|
|
101
|
+
return result
|
|
119
102
|
|
|
120
103
|
failure_id = self._failure_identity(state)
|
|
121
104
|
if not failure_id:
|
|
@@ -317,6 +300,8 @@ class RetryFailedSessionAction:
|
|
|
317
300
|
derived: DerivedState,
|
|
318
301
|
) -> ActionResult:
|
|
319
302
|
"""Dispatch one retry or publish an idempotent escalation notice."""
|
|
303
|
+
if derived.get("initial_delivery_mode"):
|
|
304
|
+
return InitialDeliveryRecovery(self).execute(provider, snapshot, derived)
|
|
320
305
|
mode = derived.get("agent_retry_mode", "")
|
|
321
306
|
failure_id = derived.get("agent_retry_failure_id", "")
|
|
322
307
|
if mode not in {"retry", "escalate"} or not isinstance(failure_id, str) or not failure_id:
|
|
@@ -458,13 +443,8 @@ class RetryFailedSessionAction:
|
|
|
458
443
|
) -> ActionResult:
|
|
459
444
|
"""Post one human-readable escalation comment, then add the failure label.
|
|
460
445
|
|
|
461
|
-
|
|
462
|
-
leaves the PR
|
|
463
|
-
explanation that justifies the exclusion. If ``add_label`` fails after
|
|
464
|
-
the comment succeeds, the PR stays schedulable and the next run only
|
|
465
|
-
needs to retry the (idempotent) label write; the inverse ordering
|
|
466
|
-
would instead let the scheduler drop the PR before the comment ever
|
|
467
|
-
exists (see ``scheduler.py``'s ``AGENT_FAILED_LABEL`` exclusion).
|
|
446
|
+
Comment-first ordering prevents scheduler exclusion without an explanation.
|
|
447
|
+
A failed label write leaves the PR schedulable for an idempotent label retry.
|
|
468
448
|
"""
|
|
469
449
|
comments = derived.get("agent_retry_comments", [])
|
|
470
450
|
dispatch_login = derived.get("agent_retry_dispatch_login", "")
|
|
@@ -513,6 +493,7 @@ class RetryFailedSessionAction:
|
|
|
513
493
|
f"This PR is excluded from the AI PR Loop. To resume, remove the `{AGENT_FAILED_LABEL}` label "
|
|
514
494
|
"and retry the task manually."
|
|
515
495
|
)
|
|
496
|
+
body = derived.get("initial_delivery_escalation_body", body)
|
|
516
497
|
try:
|
|
517
498
|
provider.post_comment_as_pr_token(snapshot.pr_number, body)
|
|
518
499
|
except Exception as exc:
|
|
@@ -522,6 +503,7 @@ class RetryFailedSessionAction:
|
|
|
522
503
|
failures.append(f"failed to post escalation: {exc}")
|
|
523
504
|
if not failures:
|
|
524
505
|
try:
|
|
506
|
+
InitialDeliveryRecovery(self).validate_escalation(snapshot, derived)
|
|
525
507
|
provider.add_label(snapshot.pr_number, AGENT_FAILED_LABEL)
|
|
526
508
|
except Exception as exc:
|
|
527
509
|
if isinstance(exc, ProviderRateLimitError) and exc.is_rate_limit:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
6
7
|
from enum import StrEnum
|
|
7
8
|
|
|
8
9
|
from agentic_devtools.cli.ci.provider import CIPlatformProvider
|
|
@@ -27,6 +28,23 @@ class InitialDeliveryOutcome(StrEnum):
|
|
|
27
28
|
UNKNOWN = "UNKNOWN"
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class InitialDeliveryEvidence:
|
|
33
|
+
"""Admission handoff from the initial-delivery observer, not from PR prose.
|
|
34
|
+
|
|
35
|
+
The caller retains the original task and authoritative plan SHA across recovery
|
|
36
|
+
runs, and supplies the classifier's typed outcome for the observed head.
|
|
37
|
+
Missing admission remains ``None``; this contract does not normalize terminal
|
|
38
|
+
signals or discover an initial-plan identity.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
outcome: InitialDeliveryOutcome
|
|
42
|
+
initial_task_id: str
|
|
43
|
+
initial_plan_sha: str
|
|
44
|
+
head_sha: str
|
|
45
|
+
push_diagnostics: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
30
48
|
def _read_pr_identity(provider: CIPlatformProvider, pr_number: int) -> tuple[str, str, str, int] | None:
|
|
31
49
|
"""Read PR base/head identity and the API-reported changed-file count."""
|
|
32
50
|
metadata = provider.get_pr_metadata(pr_number)
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
from agentic_devtools.cli.ci.approver_preflight import ApproverPreflightResult
|
|
9
9
|
from agentic_devtools.cli.ci.models import CheckRunStatus, ReviewInfo
|
|
10
10
|
from agentic_devtools.cli.ci.pipeline.critique_snapshot import CritiqueSnapshotFields
|
|
11
|
+
from agentic_devtools.cli.ci.pipeline.delivery import InitialDeliveryEvidence
|
|
11
12
|
from agentic_devtools.cli.ci.pipeline.gate_verdict import CopilotGateVerdict
|
|
12
13
|
|
|
13
14
|
|
|
@@ -18,6 +19,7 @@ class PRStateSnapshot(CritiqueSnapshotFields):
|
|
|
18
19
|
Attributes:
|
|
19
20
|
pr_number: The pull request number.
|
|
20
21
|
pr_id: The pull request database ID when the provider exposes one.
|
|
22
|
+
initial_delivery: Optional typed admission retaining the original task/plan identity.
|
|
21
23
|
head_sha: Current HEAD commit SHA.
|
|
22
24
|
base_branch: Target branch name.
|
|
23
25
|
base_sha: Target branch commit SHA captured with the snapshot.
|
|
@@ -133,6 +135,7 @@ class PRStateSnapshot(CritiqueSnapshotFields):
|
|
|
133
135
|
pr_number: int = 0
|
|
134
136
|
approver_preflight: ApproverPreflightResult | None = field(default=None, kw_only=True)
|
|
135
137
|
pr_id: int | None = field(default=None, kw_only=True)
|
|
138
|
+
initial_delivery: InitialDeliveryEvidence | None = field(default=None, kw_only=True)
|
|
136
139
|
head_sha: str = ""
|
|
137
140
|
base_branch: str = ""
|
|
138
141
|
base_sha: str = ""
|
|
@@ -122,3 +122,7 @@ class _ProviderEventsMixin(ABC):
|
|
|
122
122
|
def dispatch_agent_retry(self, pr_number: int, prompt: str, failure_event_id: str) -> int:
|
|
123
123
|
"""Dispatch one failed-session retry through a provider-specific mechanism."""
|
|
124
124
|
raise NotImplementedError(f"{type(self).__name__} does not implement dispatch_agent_retry")
|
|
125
|
+
|
|
126
|
+
def _dispatch_agent_retry(self, pr_number: int, prompt: str, key: str, *, initial_delivery: bool = False) -> int:
|
|
127
|
+
"""Internal retry-comment route for separately fenced initial delivery."""
|
|
128
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement initial-delivery dispatch")
|
|
@@ -3,13 +3,21 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
6
7
|
|
|
7
8
|
from agentic_devtools.cli.ci.models import ClosingIssue
|
|
8
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from agentic_devtools.cli.ci.initial_delivery_claims import InitialDeliveryClaims
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
class _ProviderPrLifecycleMixin(ABC):
|
|
11
15
|
"""Pull-request lifecycle and file/check capabilities."""
|
|
12
16
|
|
|
17
|
+
def _get_initial_delivery_claims(self) -> InitialDeliveryClaims:
|
|
18
|
+
"""Return durable, cross-worker claims when supported; never a local lock."""
|
|
19
|
+
raise NotImplementedError("Provider does not support durable initial-delivery claims")
|
|
20
|
+
|
|
13
21
|
@abstractmethod
|
|
14
22
|
def approve_pr(self, pr_number: int, head_sha: str, body: str) -> bool:
|
|
15
23
|
"""Approve a pull request.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
agentic_devtools/__init__.py,sha256=gvLzsZ6L3Abr4sPHr4OJ_rdZDD8cFzXacaV0LGs8dVc,539
|
|
2
|
-
agentic_devtools/_version.py,sha256=
|
|
2
|
+
agentic_devtools/_version.py,sha256=AvrHmM1z84QxURku5GjpXs4ePlchTMmZCz-bqWtaQkM,524
|
|
3
3
|
agentic_devtools/agdt_gitignore.py,sha256=vVVqTjBZ_ijn6U3Lnh_JGrARLschYVN-fFal9jDk0Ak,2694
|
|
4
4
|
agentic_devtools/background_tasks.py,sha256=sFgmqOnCdCjclABJ8V__yDwBXDyi45b1Z-MVv3G2zB8,17664
|
|
5
5
|
agentic_devtools/config.py,sha256=DEVxTVZhsQbVQwO9qdB_1h19aBpjMzF6xJY_ePYLGLs,15629
|
|
@@ -250,8 +250,9 @@ agentic_devtools/cli/ci/exceptions.py,sha256=zpKWOgnBbHHQgAQRvdRW4VFnE_qdgqgmo8r
|
|
|
250
250
|
agentic_devtools/cli/ci/finalization_state.py,sha256=jQm_cH0J09LCZfEUdZK2XQNZFhF7KhyilKcGZ4g8Wzc,8978
|
|
251
251
|
agentic_devtools/cli/ci/github_api_error.py,sha256=SvaOqag7OrnGyFMDjbl3Mr5LkjDM4bix9kXXvL5sHsQ,2382
|
|
252
252
|
agentic_devtools/cli/ci/github_pr_files.py,sha256=7QWw5adLFKMci8AtewkJfLWle3I3irF6pZ4qdhQWGU8,2697
|
|
253
|
-
agentic_devtools/cli/ci/github_provider.py,sha256=
|
|
253
|
+
agentic_devtools/cli/ci/github_provider.py,sha256=S64RFoqRdWejDzGPC5BouCUiP43fR3UnnM2_VIl9yZo,502743
|
|
254
254
|
agentic_devtools/cli/ci/guards.py,sha256=MQbtBWTpwWgNlWPJ1VJL4oZA8XciKs4J1jsN28BQBfQ,59920
|
|
255
|
+
agentic_devtools/cli/ci/initial_delivery_claims.py,sha256=Aze3eNjk0XrK8FwX87MGt0Mjz9RTDl66xdx45p659ic,9764
|
|
255
256
|
agentic_devtools/cli/ci/job_logs.py,sha256=YUiW487uO0usSwWD-mpWrs6q7IedQuYyhhBJIc0EAfk,12772
|
|
256
257
|
agentic_devtools/cli/ci/logging_config.py,sha256=1Ak6hPfqa6BhxhQ96FmpoqqeYk-aJLEdz0ekpb_gEdU,2328
|
|
257
258
|
agentic_devtools/cli/ci/models.py,sha256=H5crqYDQyuH1uou7Jjoshzf-PAd4rBOWeivTwhPt10I,21823
|
|
@@ -261,10 +262,10 @@ agentic_devtools/cli/ci/provider_audit_dispatch.py,sha256=rGc1aIvAdNSaHzLcBYgmZ_
|
|
|
261
262
|
agentic_devtools/cli/ci/provider_comments.py,sha256=f9y14qF-ePV1x-vqOgV9vskscdBSbaYDOYp1QRttfXA,4503
|
|
262
263
|
agentic_devtools/cli/ci/provider_deferrals.py,sha256=kuujg2XjIlh66HVjn53j6H4HEJ7zElkppAizJivSCGA,4101
|
|
263
264
|
agentic_devtools/cli/ci/provider_diff.py,sha256=2_Rx-A2k5rN8gRAE2wI67AtUq2Q4tTXVrA0BYmBKJDM,4208
|
|
264
|
-
agentic_devtools/cli/ci/provider_events.py,sha256
|
|
265
|
+
agentic_devtools/cli/ci/provider_events.py,sha256=ZkeVawg_od36NwOlVyFeW_HmOsA4tVhNEzhT1hE-PJk,4317
|
|
265
266
|
agentic_devtools/cli/ci/provider_issue_inventory.py,sha256=-1Zjaf0c58DdQ9QlivJXc2-b1b4xJ5qW06UpanY1Mhc,6021
|
|
266
267
|
agentic_devtools/cli/ci/provider_issue_mutations.py,sha256=_pAM0Ld7sqGNK3OhwKk-xkNqi_OUPUodWRatty5QZQo,2111
|
|
267
|
-
agentic_devtools/cli/ci/provider_pr_lifecycle.py,sha256=
|
|
268
|
+
agentic_devtools/cli/ci/provider_pr_lifecycle.py,sha256=kWPEtyfcZ9UT7xCvfUmMzSSA4uUvmAq6juGsYVLgV4s,3729
|
|
268
269
|
agentic_devtools/cli/ci/provider_repair_dispatch.py,sha256=fPg2dUN97K1UKqs4Qo2uA8owC19C0IxmHNuKYi7KVDw,3746
|
|
269
270
|
agentic_devtools/cli/ci/provider_repository.py,sha256=0FqIbTExEFN5Xrj7eq9p6OmUke2xD9MhrjqRVXFBqhE,3094
|
|
270
271
|
agentic_devtools/cli/ci/provider_reviews.py,sha256=z63kEFkBXs8kUJW-OE8tzYtEmUEtRIZPzmvFWh-KQKs,6673
|
|
@@ -312,7 +313,7 @@ agentic_devtools/cli/ci/pipeline/conflict_reconciliation.py,sha256=H_O67JPHeqByO
|
|
|
312
313
|
agentic_devtools/cli/ci/pipeline/critique_gate.py,sha256=OGaaTy_21pbPBvJSNyjTXvxWTAWYLbCSmrKARc4JYaw,3763
|
|
313
314
|
agentic_devtools/cli/ci/pipeline/critique_snapshot.py,sha256=_lJyfCLqeEw9WDMV7qQsYZljgfdDhalhoGtG9Lw0Hvo,9203
|
|
314
315
|
agentic_devtools/cli/ci/pipeline/deferral.py,sha256=M0WSLAJol9xLe1RuWNKiuRAw3EqU8VULDJqUSwM0HCw,19426
|
|
315
|
-
agentic_devtools/cli/ci/pipeline/delivery.py,sha256=
|
|
316
|
+
agentic_devtools/cli/ci/pipeline/delivery.py,sha256=2CRoAQtnNJuI6T8B9pxK3CENM222LF9UbYUbfTJIsn8,6998
|
|
316
317
|
agentic_devtools/cli/ci/pipeline/delivery_bridge.py,sha256=p_cO6s9lvS60iua1dO-3ttpU6D_DNbYvyelVxW2VnlQ,2694
|
|
317
318
|
agentic_devtools/cli/ci/pipeline/diff_validation.py,sha256=LRf-FB2sIap6kLCcqZIw3LFXhRFgRqYIaHtCY7hPtwQ,2678
|
|
318
319
|
agentic_devtools/cli/ci/pipeline/exceptions.py,sha256=4UXy2weeQZex_lp4sILNxlYfGjetxbR-tjms2ReT4iw,451
|
|
@@ -326,7 +327,7 @@ agentic_devtools/cli/ci/pipeline/runner_diff_parser.py,sha256=T3ofZoUxKGZaGSyx5d
|
|
|
326
327
|
agentic_devtools/cli/ci/pipeline/session_detector.py,sha256=FdwjFZG0LZ7TDmcO9Fwjr9xPbcDqnAxKho7SCSTDo50,66251
|
|
327
328
|
agentic_devtools/cli/ci/pipeline/snapshot_builder.py,sha256=WxMSAsfK1h1StwkWAOVHgaorvBpKyjeCAcD1ekGHt8g,20116
|
|
328
329
|
agentic_devtools/cli/ci/pipeline/snapshot_ci.py,sha256=nvtqRVSEnjWUSCXLEUVa9-JBmMXV08cOLEczogorNTc,4531
|
|
329
|
-
agentic_devtools/cli/ci/pipeline/snapshot_models.py,sha256=
|
|
330
|
+
agentic_devtools/cli/ci/pipeline/snapshot_models.py,sha256=htimdF2kUdcnNzIlhbZ_6bT-dzo72bVeMqgxnr0dX0c,12610
|
|
330
331
|
agentic_devtools/cli/ci/pipeline/snapshot_review_comments.py,sha256=GuPKNBJ1g8-nrEcL7QRbMx-lUKwqrBfJlzb2q0z5UeA,7172
|
|
331
332
|
agentic_devtools/cli/ci/pipeline/snapshot_reviews.py,sha256=wQtzxgw3-0s4m_5flqYYXTu45jLhTRaCkmdclSlPu-c,2837
|
|
332
333
|
agentic_devtools/cli/ci/pipeline/snapshot_thread_count.py,sha256=QpNI383E3wJyb3jBOkcgvhpxY7cgn9yjyi1DZTlAZBA,7633
|
|
@@ -346,12 +347,13 @@ agentic_devtools/cli/ci/pipeline/actions/defer_suppressed.py,sha256=98ixxe4ml2pC
|
|
|
346
347
|
agentic_devtools/cli/ci/pipeline/actions/dispatch_conflict_resolution.py,sha256=gPnQpukH1_oLYIybLnmeEaZofHLdsugDF9VBk6yAJKU,14731
|
|
347
348
|
agentic_devtools/cli/ci/pipeline/actions/dispatch_repair.py,sha256=g-xkmCh6rUCPdUuUAjb7MISTCc0dgJUB3M-7N6fdo_w,39760
|
|
348
349
|
agentic_devtools/cli/ci/pipeline/actions/guards.py,sha256=922i276VJhRU3sZpq9tYqRAjLr7sXVIPRkD17l0jfNg,5842
|
|
350
|
+
agentic_devtools/cli/ci/pipeline/actions/initial_delivery_recovery.py,sha256=7KRTobupqdfXYPDzXiLRgbTKzu1K8XO09vjeu7yoCMA,24138
|
|
349
351
|
agentic_devtools/cli/ci/pipeline/actions/merge.py,sha256=mWbmDxZmlrq8wAQl_oMR70LsdFUc5vU8rt_ZQ-QEXmk,20936
|
|
350
352
|
agentic_devtools/cli/ci/pipeline/actions/publish.py,sha256=w3rqB1OXGR_SeeRqWUGNCaxZb4-oljptMgzw374KEz4,10806
|
|
351
353
|
agentic_devtools/cli/ci/pipeline/actions/rebase.py,sha256=CwNk6tZlkaTG6-jm1lRPGsyLyo3sdjwQZy6kMjIc55k,5883
|
|
352
354
|
agentic_devtools/cli/ci/pipeline/actions/request_review.py,sha256=uvlr4Vm9KRWIKGHtBF9i9gPsDAZLskn3bLiXuxxdlmI,11759
|
|
353
355
|
agentic_devtools/cli/ci/pipeline/actions/resolve_threads.py,sha256=SvlAFVF5kL6unYPvxYhvF_zas85LBysOWgGR52kFCdE,12719
|
|
354
|
-
agentic_devtools/cli/ci/pipeline/actions/retry_failed_session.py,sha256=
|
|
356
|
+
agentic_devtools/cli/ci/pipeline/actions/retry_failed_session.py,sha256=vAiLRKt_PtqgUE5u1AP-3U-siARrIdWHbjh3YAtV3-I,24836
|
|
355
357
|
agentic_devtools/cli/ci/pipeline/actions/squash.py,sha256=efdxkjwvJFvmYu9w1M0lnMEGiokSpUD4CQnCaDj0dKs,11643
|
|
356
358
|
agentic_devtools/cli/ci/pipeline/actions/takeover.py,sha256=-QMvoJTvGsu2DDQFUkVUwZcbK34A7HIvgh0hdFrnFr4,8030
|
|
357
359
|
agentic_devtools/cli/ci/pipeline/discovery/__init__.py,sha256=QNdOYvl_wK6qcWOvNJR-wm8EtWD_DPn4barKF5RSmfU,752
|
|
@@ -1243,10 +1245,10 @@ agentic_devtools/_bundled_skills/skills/agdt-analyze-workflow/SKILL.md,sha256=JV
|
|
|
1243
1245
|
agentic_devtools/_bundled_skills/skills/agdt-analyze-workflow/lifecycle.md,sha256=DmYCXNaf8W0hq5dgKyowvdZ5brkTofDVTgXmLBnbYCw,12416
|
|
1244
1246
|
agentic_devtools/_bundled_skills/skills/agdt-analyze-workflow/workflow-analysis.md,sha256=ZssIwWCMsmgHYRFNv_NG088YovFvXcms1RUktaSGyaI,21997
|
|
1245
1247
|
agentic_devtools/_bundled_skills/skills/agdt-apply-pr-suggestions-initiate/SKILL.md,sha256=sMIEi3Pjse5hDpk5O2D8bXKqjuWNw7L28XlNhFaToSA,797
|
|
1246
|
-
agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/SKILL.md,sha256=
|
|
1248
|
+
agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/SKILL.md,sha256=0JqrZAzZ-3Rf_FO-GDGIBUg1mg0qr5UwkURhxU9jcbU,35149
|
|
1247
1249
|
agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/command-reference.md,sha256=TOIWIRyaldLNyyPipEN8pkH4wNHGYnw4ay3m9MAf1_8,4186
|
|
1248
1250
|
agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/execution-procedures.md,sha256=1GmQpgsdwDI0vf6-sNXyLgZVV5W1u_fkf44ZX9XLu4I,8394
|
|
1249
|
-
agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/specification-templates.md,sha256=
|
|
1251
|
+
agentic_devtools/_bundled_skills/skills/agdt-autonomous-issue-refinement/specification-templates.md,sha256=gvHy2JHmd_2tDTQfcIh2mqc4OIymRjltilW0MiupkAo,7130
|
|
1250
1252
|
agentic_devtools/_bundled_skills/skills/agdt-break-down-issue-into-subtasks-initiate/SKILL.md,sha256=i68KWw4c6wLrGzSFsbc1ynR4yvx9g46z5qsqsgwXLlk,2073
|
|
1251
1253
|
agentic_devtools/_bundled_skills/skills/agdt-create-issues-from-analysis/SKILL.md,sha256=S_838usj0oWllUhoL5sKMmU8IcQc1HKsoNX4dhr0mac,11937
|
|
1252
1254
|
agentic_devtools/_bundled_skills/skills/agdt-create-issues-from-analysis/analysis-output-contract.md,sha256=bX2UmlQohmgyrLqQ7IFTmTEobaHJ-pWzvwqIROYYvRA,821
|
|
@@ -1303,8 +1305,8 @@ agentic_devtools/_bundled_skills/skills/speckit-plan/SKILL.md,sha256=qYLpzgLCIZ6
|
|
|
1303
1305
|
agentic_devtools/_bundled_skills/skills/speckit-specify/SKILL.md,sha256=HbHRbdphMqbtNw6fL6NcYCj6sSFmUyk2S4U0bZ9p_l4,13681
|
|
1304
1306
|
agentic_devtools/_bundled_skills/skills/write-github-commit-message/SKILL.md,sha256=Ep1lR8NDN117Iytj172VZgqazFiLpJ56Z7RXgaTnxR8,2626
|
|
1305
1307
|
agentic_devtools/_bundled_skills/skills/write-github-commit-message/commit-types.md,sha256=bTy_t68ZD6R87rFIzq9wybTu8Wudoahb7nB9IWC4bFo,1839
|
|
1306
|
-
agentic_devtools-0.2.
|
|
1307
|
-
agentic_devtools-0.2.
|
|
1308
|
-
agentic_devtools-0.2.
|
|
1309
|
-
agentic_devtools-0.2.
|
|
1310
|
-
agentic_devtools-0.2.
|
|
1308
|
+
agentic_devtools-0.2.596.dist-info/METADATA,sha256=LpdqiT1tbaT8eB4vtvWOdBDdCX5ZY7TdRs7odTvahHs,35589
|
|
1309
|
+
agentic_devtools-0.2.596.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
1310
|
+
agentic_devtools-0.2.596.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
|
|
1311
|
+
agentic_devtools-0.2.596.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
|
|
1312
|
+
agentic_devtools-0.2.596.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|