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.
@@ -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 4](#33-feasibility-and-evidence-gate). If the
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. For every implementation-dependent claim about an external tool, build backend, service, or
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
- 5. Record the gate result using the format below. For each criterion, mark `PASS`, `FAIL`, or
329
- `UNVERIFIED` and cite the trace, path authority, or external evidence that supports the result.
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 pass requires every criterion and required path/claim to be
332
- evidenced, with no failed or unverified item. This is a content-level workflow check, not a
333
- deterministic code-level enforcement mechanism.
334
- 6. If multiple independent behaviors remain, use [Abort: Scope Violation](#abort-scope-violation).
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
- 7. After a pass, carry the gate result into the `Feasibility Gate Result` section of the
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
 
@@ -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
@@ -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.595'
22
- __version_tuple__ = version_tuple = (0, 2, 595)
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
- @retry_with_backoff(max_retries=0)
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
- marker_pattern = re.compile(rf"<!-- agdt:agent-retry:{re.escape(failure_event_id)}:(\d+) -->")
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(r"<!-- agdt:agent-retry:[^:\s>]+:\d+ -->", "", metadata)
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
- reserved = len(prefix) + len(suffix)
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 in _agent_retry_comment_header(comment.body)
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
- if state.outcome is SessionOutcome.UNKNOWN:
99
- return ActionResult(
100
- name=self.name,
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
- The comment is posted before the label so a partial failure never
462
- leaves the PR excluded (label present) without the human-readable
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,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-devtools
3
- Version: 0.2.595
3
+ Version: 0.2.596
4
4
  Summary: Agentic devtools integrate Jira, DevOps & more
5
5
  Author: ayaiayorg
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  agentic_devtools/__init__.py,sha256=gvLzsZ6L3Abr4sPHr4OJ_rdZDD8cFzXacaV0LGs8dVc,539
2
- agentic_devtools/_version.py,sha256=yu1a204qVZRleC_5c0LoHgt2mwgkkFjwyJGisw2mRkk,524
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=xRnK2apdAt09qfdPTLguDGSJ1l8OVlxh-SPegpSGTFo,502091
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=-iIE8imxDU-WcyrfosV2_SKP747pOA19nzBzE0n9VLA,4010
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=rdO2y-OnaBTLMLBIIqbOSmrfxkGsJvLRfOk42TYGra0,3340
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=zUg3AlVd8KmKgdWHYi4_e1oKbstmVTBRdQpRHBJDaws,6388
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=NP02aqTG8L1N3v0Wa8DSeaiYGcNHwXaJQBqpwnxCY7E,12349
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=BBEMmOwHeteUabAN6WfDCdHRE9N4xlXkiVsWbgm6fHA,25615
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=KA4gaBg3XQa3tL8koSa3v-65M7m4WpbG713ioqMB3q4,29297
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=FORZ1fCmseI5SOrYf57T11yBZBroO5KAw77oTxYicxw,4499
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.595.dist-info/METADATA,sha256=wc355Gd-s_CeC7R7O0PmHZ4_-5sYEmFEW282w1RBPwY,35589
1307
- agentic_devtools-0.2.595.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
1308
- agentic_devtools-0.2.595.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
1309
- agentic_devtools-0.2.595.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
1310
- agentic_devtools-0.2.595.dist-info/RECORD,,
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,,