agentic-devtools 0.2.613__py3-none-any.whl → 0.2.615__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.
@@ -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.613'
22
- __version_tuple__ = version_tuple = (0, 2, 613)
21
+ __version__ = version = '0.2.615'
22
+ __version_tuple__ = version_tuple = (0, 2, 615)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -134,3 +134,146 @@ def select_task_lifecycle_timestamp(task: AgentTaskLifecycle) -> datetime | None
134
134
  if session_timestamp is not None:
135
135
  timestamps.append(session_timestamp)
136
136
  return max(timestamps, default=None)
137
+
138
+
139
+ def _agent_task_lifecycle_detail_value(record: Mapping[str, object], *keys: str) -> object:
140
+ """Return the first matching record or metadata alias."""
141
+ for key in keys:
142
+ if key in record:
143
+ return record[key]
144
+ metadata = record.get("metadata")
145
+ if isinstance(metadata, dict):
146
+ for key in keys:
147
+ if key in metadata:
148
+ return metadata[key]
149
+ return None
150
+
151
+
152
+ def _map_agent_task_lifecycle_session(
153
+ task: Mapping[str, object],
154
+ session: object,
155
+ *,
156
+ task_id: str,
157
+ task_state: str,
158
+ top_level_session_record: bool,
159
+ ) -> tuple[AgentTaskSessionLifecycle | None, tuple[str, ...]]:
160
+ """Normalize one session record while preserving identity and malformed-data facts."""
161
+ if not isinstance(session, dict):
162
+ return None, (f"malformed-session:{task_id}",)
163
+ reasons: list[str] = []
164
+ session_id_value = _agent_task_lifecycle_detail_value(session, "session_id", "sessionId") or (
165
+ _agent_task_lifecycle_detail_value(task, "session_id", "sessionId")
166
+ if top_level_session_record
167
+ else _agent_task_lifecycle_detail_value(session, "id")
168
+ )
169
+ if not isinstance(session_id_value, str) or not session_id_value:
170
+ reasons.append(f"missing-session-id:{task_id}")
171
+ session_id_value = ""
172
+ session_state_value = _agent_task_lifecycle_detail_value(session, "state", "status")
173
+ if top_level_session_record:
174
+ session_state_value = task_state
175
+ started, session_created_at, idle = session_lifecycle_timestamp_values(
176
+ session,
177
+ include_created=not top_level_session_record,
178
+ fallback=task if top_level_session_record else None,
179
+ )
180
+ session_state = session_state_value if isinstance(session_state_value, str) else ""
181
+ session_head_sha = _agent_task_lifecycle_detail_value(session, "head_sha", "headSha")
182
+ head_sha = session_head_sha if isinstance(session_head_sha, str) else ""
183
+ session_run_id = _agent_task_lifecycle_detail_value(session, "workflow_run_id", "workflowRunId", "run_id", "runId")
184
+ run_id = (
185
+ str(session_run_id) if isinstance(session_run_id, (str, int)) and not isinstance(session_run_id, bool) else ""
186
+ )
187
+ session_values = (session_state, started, session_created_at, idle, head_sha, run_id)
188
+ if not session_id_value and not any(session_values):
189
+ return None, tuple(reasons)
190
+ return (
191
+ AgentTaskSessionLifecycle(
192
+ session_id=session_id_value,
193
+ state=session_state,
194
+ started_at=started,
195
+ idle_at=idle,
196
+ head_sha=head_sha,
197
+ workflow_run_id=run_id,
198
+ created_at=session_created_at,
199
+ ),
200
+ tuple(reasons),
201
+ )
202
+
203
+
204
+ def _map_agent_task_lifecycle_sessions(
205
+ task: Mapping[str, object],
206
+ detail: Mapping[str, object],
207
+ *,
208
+ task_id: str,
209
+ task_state: str,
210
+ ) -> tuple[tuple[AgentTaskSessionLifecycle, ...], tuple[str, ...]]:
211
+ """Normalize nested or singleton sessions and return task-scoped diagnostics."""
212
+ session_records = detail.get("sessions", task.get("sessions"))
213
+ reasons: list[str] = []
214
+ top_level_session_record = False
215
+ if not isinstance(session_records, list):
216
+ if session_records is not None:
217
+ reasons.append(f"malformed-session:{task_id}")
218
+ top_level_session_record = bool(
219
+ _agent_task_lifecycle_detail_value(detail, "session_id", "sessionId")
220
+ or has_session_activity_timestamp(detail, fallback=task)
221
+ )
222
+ session_records = [detail] if top_level_session_record else []
223
+ sessions: list[AgentTaskSessionLifecycle] = []
224
+ for session in session_records:
225
+ normalized, diagnostics = _map_agent_task_lifecycle_session(
226
+ task,
227
+ session,
228
+ task_id=task_id,
229
+ task_state=task_state,
230
+ top_level_session_record=top_level_session_record,
231
+ )
232
+ reasons.extend(diagnostics)
233
+ if normalized is not None:
234
+ sessions.append(normalized)
235
+ return tuple(sessions), tuple(reasons)
236
+
237
+
238
+ def _map_agent_task_lifecycle_detail(
239
+ task: Mapping[str, object],
240
+ detail: Mapping[str, object],
241
+ *,
242
+ task_id: str,
243
+ repository: str,
244
+ pr_number: int,
245
+ task_pr_number: int | None,
246
+ head_branch: str,
247
+ ) -> tuple[AgentTaskLifecycle, tuple[str, ...]]:
248
+ """Normalize one fetched task detail record and retain task-scoped diagnostics."""
249
+ state_value = _agent_task_lifecycle_detail_value(detail, "state", "status")
250
+ if not isinstance(state_value, str) or not state_value.strip():
251
+ state_value = _agent_task_lifecycle_detail_value(task, "state", "status")
252
+ state = state_value if isinstance(state_value, str) else ""
253
+ created_at = select_valid_timestamp_alias(detail, "created_at", "createdAt", fallback=task)
254
+ started_at = select_valid_timestamp_alias(detail, "started_at", "startedAt", fallback=task)
255
+ idle_at = select_valid_timestamp_alias(detail, "idle_at", "idleAt", "idle_since", "idleSince", fallback=task)
256
+ sessions, reasons = _map_agent_task_lifecycle_sessions(
257
+ task,
258
+ detail,
259
+ task_id=task_id,
260
+ task_state=state,
261
+ )
262
+ task_head_sha = _agent_task_lifecycle_detail_value(detail, "head_sha", "headSha")
263
+ task_run_id = _agent_task_lifecycle_detail_value(detail, "workflow_run_id", "workflowRunId", "run_id", "runId")
264
+ lifecycle = AgentTaskLifecycle(
265
+ task_id=task_id,
266
+ repository=repository,
267
+ pr_number=task_pr_number if task_pr_number is not None else pr_number,
268
+ head_branch=head_branch,
269
+ state=state,
270
+ created_at=created_at,
271
+ sessions=sessions,
272
+ head_sha=task_head_sha if isinstance(task_head_sha, str) else "",
273
+ workflow_run_id=str(task_run_id)
274
+ if isinstance(task_run_id, (str, int)) and not isinstance(task_run_id, bool)
275
+ else "",
276
+ started_at=started_at,
277
+ idle_at=idle_at,
278
+ )
279
+ return lifecycle, reasons
@@ -23,12 +23,8 @@ from agentic_devtools.cli.ci.credential_roles import (
23
23
  REPO_VARIABLE_WRITER_PAT,
24
24
  )
25
25
  from agentic_devtools.cli.shared.retry import (
26
- DEFAULT_RATE_LIMIT_FALLBACK_DELAY,
27
26
  DEFAULT_RATE_LIMIT_MAX_DELAY,
28
- DEFAULT_RATE_LIMIT_SAFETY_MARGIN,
29
27
  ProviderRateLimitError,
30
- RateLimitDelay,
31
- calculate_rate_limit_delay,
32
28
  )
33
29
 
34
30
  if TYPE_CHECKING:
@@ -234,13 +230,13 @@ def read_cooldowns(
234
230
  ) -> dict[str, CooldownRecord]:
235
231
  """Read active cooldowns; unavailable state fails open for scheduling."""
236
232
  try:
237
- return parse_cooldowns(provider.get_variable(COOLDOWN_VARIABLE, use_writer_token=use_writer_token), now=now)
238
- except ProviderRateLimitError as exc:
239
- if exc.is_rate_limit:
240
- raise
241
- logger.warning("Could not read provider cooldowns; continuing fail-open: %s", type(exc).__name__)
242
- return {}
233
+ from agentic_devtools.cli.ci.read_token_pool_execution import _read_cooldown_value
234
+
235
+ value = _read_cooldown_value(provider, use_writer_token=use_writer_token)
236
+ return parse_cooldowns(value, now=now)
243
237
  except Exception as exc:
238
+ if isinstance(exc, ProviderRateLimitError) and exc.is_rate_limit:
239
+ raise
244
240
  logger.warning("Could not read provider cooldowns; continuing fail-open: %s", type(exc).__name__)
245
241
  return {}
246
242
 
@@ -270,68 +266,12 @@ def persist_cooldown(
270
266
  *,
271
267
  now: float | None = None,
272
268
  retries: int = 3,
269
+ observed_at: float | None = None,
273
270
  ) -> tuple[str, CooldownRecord] | None:
274
- """Persist a monotonic provider cooldown using bounded read/merge/write attempts.
271
+ """Persist a provider cooldown, optionally anchoring its expiry to its observation time."""
272
+ from agentic_devtools.cli.ci.cooldown_persistence import persist_cooldown as persist
275
273
 
276
- Cross-process safety relies on the read/merge/verify/retry loop: each attempt
277
- reads the current variable, merges all known records, writes the merged state,
278
- and verifies that no concurrent writer shortened an active cooldown. The
279
- in-process ``_WRITE_LOCK`` serializes concurrent callers within the same process.
280
- """
281
- current = time.time() if now is None else float(now)
282
- identity = error.credential_identity or credential_identity_from_environment()
283
- key = cooldown_key(identity, provider=error.provider or DEFAULT_PROVIDER)
284
- delay: RateLimitDelay = calculate_rate_limit_delay(
285
- retry_after_seconds=error.retry_after_seconds,
286
- reset_timestamp=error.reset_timestamp,
287
- now=current,
288
- fallback_delay=DEFAULT_RATE_LIMIT_FALLBACK_DELAY,
289
- safety_margin=DEFAULT_RATE_LIMIT_SAFETY_MARGIN,
290
- max_delay=DEFAULT_RATE_LIMIT_MAX_DELAY,
291
- )
292
- record = CooldownRecord(
293
- resume_at=delay.resume_at,
294
- reason="rate_limit",
295
- source=error.source if error.source in _ALLOWED_SOURCE_VALUES else delay.source,
296
- updated_at=current,
297
- )
298
- known_records: dict[str, CooldownRecord] = {}
299
- with _WRITE_LOCK:
300
- for _ in range(max(1, retries)):
301
- try:
302
- raw = provider.get_variable(COOLDOWN_VARIABLE, use_writer_token=True)
303
- observed_records = parse_cooldowns(raw, now=current)
304
- for observed_key, observed_record in observed_records.items():
305
- known_record = known_records.get(observed_key)
306
- if known_record is None or (
307
- observed_record.resume_at > known_record.resume_at
308
- or (
309
- observed_record.resume_at == known_record.resume_at
310
- and observed_record.updated_at > known_record.updated_at
311
- )
312
- ):
313
- known_records[observed_key] = observed_record
314
- records = dict(known_records)
315
- existing = records.get(key)
316
- if existing is None or existing.resume_at < record.resume_at:
317
- records[key] = record
318
- merged = serialize_cooldowns(records)
319
- provider.set_variable(COOLDOWN_VARIABLE, merged)
320
- verified_raw = provider.get_variable(COOLDOWN_VARIABLE, use_writer_token=True)
321
- verified_records = parse_cooldowns(verified_raw, now=current)
322
- effective = verified_records.get(key)
323
- if not _preserves_cooldown_records(records, verified_records):
324
- logger.warning("Provider cooldown write was stale; reconciling records before retrying")
325
- continue
326
- if effective is None: # pragma: no cover - covered by the preservation check
327
- continue
328
- return key, effective
329
- except Exception as exc:
330
- logger.warning("Could not persist provider cooldown; retrying: %s", type(exc).__name__)
331
- if _ + 1 < max(1, retries):
332
- time.sleep(1.0)
333
- logger.warning("Provider cooldown could not be persisted; keeping rate-limit pause in effect")
334
- return None
274
+ return persist(provider, error, now=now, retries=retries, observed_at=observed_at)
335
275
 
336
276
 
337
277
  def format_resume_at(timestamp: float) -> str:
@@ -0,0 +1,89 @@
1
+ """Persistence implementation for durable provider cooldowns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import TYPE_CHECKING
8
+
9
+ import agentic_devtools.cli.ci.cooldown as cooldown
10
+ from agentic_devtools.cli.shared.retry import (
11
+ DEFAULT_RATE_LIMIT_FALLBACK_DELAY,
12
+ DEFAULT_RATE_LIMIT_MAX_DELAY,
13
+ DEFAULT_RATE_LIMIT_SAFETY_MARGIN,
14
+ ProviderRateLimitError,
15
+ RateLimitDelay,
16
+ calculate_rate_limit_delay,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from agentic_devtools.cli.ci.provider import CIPlatformProvider
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def persist_cooldown(
26
+ provider: CIPlatformProvider,
27
+ error: ProviderRateLimitError,
28
+ *,
29
+ now: float | None = None,
30
+ retries: int = 3,
31
+ observed_at: float | None = None,
32
+ ) -> tuple[str, cooldown.CooldownRecord] | None:
33
+ """Persist a cooldown using current time for validation and observation time for its expiry."""
34
+ current = time.time() if now is None else float(now)
35
+ event_time = current if observed_at is None else float(observed_at)
36
+ identity = error.credential_identity or cooldown.credential_identity_from_environment()
37
+ key = cooldown.cooldown_key(identity, provider=error.provider or cooldown.DEFAULT_PROVIDER)
38
+ delay: RateLimitDelay = calculate_rate_limit_delay(
39
+ retry_after_seconds=error.retry_after_seconds,
40
+ reset_timestamp=error.reset_timestamp,
41
+ now=event_time,
42
+ fallback_delay=DEFAULT_RATE_LIMIT_FALLBACK_DELAY,
43
+ safety_margin=DEFAULT_RATE_LIMIT_SAFETY_MARGIN,
44
+ max_delay=DEFAULT_RATE_LIMIT_MAX_DELAY,
45
+ )
46
+ record = cooldown.CooldownRecord(
47
+ resume_at=delay.resume_at,
48
+ reason="rate_limit",
49
+ source=error.source if error.source in cooldown._ALLOWED_SOURCE_VALUES else delay.source,
50
+ updated_at=event_time,
51
+ )
52
+ known_records: dict[str, cooldown.CooldownRecord] = {}
53
+ with cooldown._WRITE_LOCK:
54
+ for attempt in range(max(1, retries)):
55
+ try:
56
+ raw = provider.get_variable(cooldown.COOLDOWN_VARIABLE, use_writer_token=True)
57
+ observed_records = cooldown.parse_cooldowns(raw, now=current)
58
+ for observed_key, observed_record in observed_records.items():
59
+ known_record = known_records.get(observed_key)
60
+ if known_record is None or (
61
+ observed_record.resume_at > known_record.resume_at
62
+ or (
63
+ observed_record.resume_at == known_record.resume_at
64
+ and observed_record.updated_at > known_record.updated_at
65
+ )
66
+ ):
67
+ known_records[observed_key] = observed_record
68
+ records = dict(known_records)
69
+ existing = records.get(key)
70
+ if existing is None or existing.resume_at < record.resume_at:
71
+ records[key] = record
72
+ merged = cooldown.serialize_cooldowns(records)
73
+ provider.set_variable(cooldown.COOLDOWN_VARIABLE, merged)
74
+ verified_raw = provider.get_variable(cooldown.COOLDOWN_VARIABLE, use_writer_token=True)
75
+ verified_records = cooldown.parse_cooldowns(verified_raw, now=current)
76
+ effective = verified_records.get(key)
77
+ expected_records = {item_key: item for item_key, item in records.items() if item.resume_at > current}
78
+ if not cooldown._preserves_cooldown_records(expected_records, verified_records):
79
+ logger.warning("Provider cooldown write was stale; reconciling records before retrying")
80
+ continue
81
+ if effective is None:
82
+ return None
83
+ return key, effective
84
+ except Exception as exc:
85
+ logger.warning("Could not persist provider cooldown; retrying: %s", type(exc).__name__)
86
+ if attempt + 1 < max(1, retries):
87
+ time.sleep(1.0)
88
+ logger.warning("Provider cooldown could not be persisted; keeping rate-limit pause in effect")
89
+ return None
@@ -93,6 +93,7 @@ from agentic_devtools.cli.ci.provider import (
93
93
  RepairDispatchNotAttemptedError,
94
94
  )
95
95
  from agentic_devtools.cli.ci.read_token_pool import ReadTokenPool
96
+ from agentic_devtools.cli.ci.read_token_pool_execution import ReadTokenPoolOperations
96
97
  from agentic_devtools.cli.ci.reconciliation import config
97
98
  from agentic_devtools.cli.ci.reconciliation.models import ObservationOutcome
98
99
  from agentic_devtools.cli.ci.resolution.engine import TieredResolutionEngine
@@ -2317,6 +2318,9 @@ def _build_repair_comment(
2317
2318
  return _enforce_comment_size_limit(body)
2318
2319
 
2319
2320
 
2321
+ _InventoryDeadlineExceeded = ReadTokenPoolOperations.DeadlineExpired
2322
+
2323
+
2320
2324
  def _gh_api(
2321
2325
  endpoint: str,
2322
2326
  *,
@@ -2330,6 +2334,9 @@ def _gh_api(
2330
2334
  response_metadata: dict[str, float | int | None] | None = None,
2331
2335
  read_pool: bool = True,
2332
2336
  timeout_seconds: int = _GH_CLI_TIMEOUT_SECONDS,
2337
+ _deadline: float | None = None,
2338
+ _monotonic: Callable[[], float] | None = None,
2339
+ _pool_managed: bool = False,
2333
2340
  ) -> str:
2334
2341
  """Call the GitHub API via the ``gh`` CLI.
2335
2342
 
@@ -2357,12 +2364,15 @@ def _gh_api(
2357
2364
  """
2358
2365
  if not isinstance(timeout_seconds, int) or isinstance(timeout_seconds, bool) or timeout_seconds <= 0:
2359
2366
  raise ValueError("timeout_seconds must be a positive integer")
2367
+ _deadline, _monotonic = ReadTokenPoolOperations.resolve_request_context(_deadline, _monotonic)
2360
2368
  target_repository = _target_repository_from_gh_arguments(["api", endpoint])
2361
2369
  if method.upper() == "GET" and _should_use_read_token_pool(
2362
2370
  token=token,
2363
2371
  credential_identity=credential_identity,
2364
2372
  read_pool=read_pool,
2365
2373
  target_repository=target_repository,
2374
+ deadline=_deadline,
2375
+ monotonic=_monotonic,
2366
2376
  ):
2367
2377
 
2368
2378
  def pooled_request(selected_token: str | None, identity: str) -> tuple[str, int | None]:
@@ -2379,13 +2389,21 @@ def _gh_api(
2379
2389
  response_metadata=request_metadata,
2380
2390
  read_pool=False,
2381
2391
  timeout_seconds=timeout_seconds,
2392
+ _deadline=_deadline,
2393
+ _monotonic=_monotonic,
2394
+ _pool_managed=True,
2382
2395
  )
2383
2396
  if response_metadata is not None:
2384
2397
  response_metadata.update(request_metadata)
2385
2398
  remaining_value = request_metadata.get("remaining")
2386
2399
  return response, remaining_value if isinstance(remaining_value, int) else None
2387
2400
 
2388
- return _call_with_read_token_pool("rest", pooled_request)
2401
+ return _call_with_read_token_pool(
2402
+ "rest",
2403
+ pooled_request,
2404
+ deadline=_deadline,
2405
+ monotonic=_monotonic,
2406
+ )
2389
2407
 
2390
2408
  cmd = ["gh", "api", endpoint, "--method", method]
2391
2409
  if include_headers:
@@ -2407,16 +2425,21 @@ def _gh_api(
2407
2425
  is_agent_tasks = endpoint.partition("?")[0].startswith(AGENT_TASKS_ENDPOINT_TEMPLATE.partition("{owner}")[0])
2408
2426
  diagnostics = AgentTaskDiagnostics(endpoint, env, _sanitize_gh_error, is_agent_tasks=is_agent_tasks)
2409
2427
 
2410
- result = diagnostics.run(
2411
- run_safe,
2412
- cmd,
2413
- f"gh api {endpoint} timed out after {timeout_seconds}s",
2414
- capture_output=True,
2415
- text=True,
2416
- shell=False,
2417
- input=json.dumps(body) if body else None,
2418
- env=env,
2419
- timeout=timeout_seconds,
2428
+ request_timeout_seconds = ReadTokenPoolOperations.request_timeout(timeout_seconds, _deadline, _monotonic)
2429
+ result = ReadTokenPoolOperations.run_request(
2430
+ lambda: diagnostics.run(
2431
+ run_safe,
2432
+ cmd,
2433
+ f"gh api {endpoint} timed out after {request_timeout_seconds}s",
2434
+ capture_output=True,
2435
+ text=True,
2436
+ shell=False,
2437
+ input=json.dumps(body) if body else None,
2438
+ env=env,
2439
+ timeout=request_timeout_seconds,
2440
+ ),
2441
+ deadline=_deadline,
2442
+ monotonic=_monotonic,
2420
2443
  )
2421
2444
  if include_headers:
2422
2445
  status, response_headers, response_body = _parse_gh_api_output(result.stdout or "")
@@ -2435,6 +2458,12 @@ def _gh_api(
2435
2458
  remaining=remaining,
2436
2459
  )
2437
2460
  if result.returncode != 0:
2461
+ ReadTokenPoolOperations.check_http_error_deadline(
2462
+ _deadline,
2463
+ _monotonic,
2464
+ pool_managed=_pool_managed,
2465
+ is_rate_limit=is_rate_limit,
2466
+ )
2438
2467
  if is_rate_limit:
2439
2468
  raise RetryableError(
2440
2469
  diagnostics.format_error(f"GitHub API rate limited (HTTP {status or 429})"),
@@ -2499,10 +2528,17 @@ def _extract_existing_pr_url(text: str) -> str:
2499
2528
  return match.group(0) if match else ""
2500
2529
 
2501
2530
 
2502
- def _get_read_token_pool() -> ReadTokenPool | None:
2531
+ def _get_read_token_pool(
2532
+ *,
2533
+ deadline: float | None = None,
2534
+ monotonic: Callable[[], float] | None = None,
2535
+ ) -> ReadTokenPool | None:
2503
2536
  """Create and cache the validated read-token pool for this process and repository."""
2504
2537
  global _READ_TOKEN_POOL, _READ_TOKEN_POOL_REPO
2505
2538
 
2539
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
2540
+ if deadline is None:
2541
+ ReadTokenPoolOperations.flush_deferred_rate_limits()
2506
2542
  repo = os.environ.get("GITHUB_REPOSITORY", "").strip()
2507
2543
  secrets_json = os.environ.get("AGDT_GITHUB_SECRETS_JSON")
2508
2544
  if not repo or not secrets_json:
@@ -2522,6 +2558,7 @@ def _get_read_token_pool() -> ReadTokenPool | None:
2522
2558
  credential_identity=identity,
2523
2559
  response_metadata=metadata,
2524
2560
  read_pool=False,
2561
+ _pool_managed=True,
2525
2562
  )
2526
2563
  remaining = metadata.get("remaining")
2527
2564
  return response, remaining if isinstance(remaining, int) else None
@@ -2545,39 +2582,29 @@ def _get_read_token_pool() -> ReadTokenPool | None:
2545
2582
  return {key.partition(":")[2]: record.resume_at for key, record in records.items() if key.startswith("github:")}
2546
2583
 
2547
2584
  def persist_rate_limit(identity: str, error: RetryableError | ProviderRateLimitError) -> float | None:
2548
- from agentic_devtools.cli.ci.cooldown import persist_cooldown
2549
-
2550
- retry_after = error.retry_after if isinstance(error, RetryableError) else error.retry_after_seconds
2551
- bypass = _READ_POOL_BYPASS.set(True)
2552
- try:
2553
- persisted = persist_cooldown(
2554
- provider,
2555
- ProviderRateLimitError(
2556
- retry_after_seconds=retry_after,
2557
- reset_timestamp=error.reset_timestamp,
2558
- remaining=error.remaining,
2559
- provider=error.provider or "github",
2560
- credential_identity=identity,
2561
- source=error.source,
2562
- is_rate_limit=error.is_rate_limit,
2563
- ),
2564
- )
2565
- return persisted[1].resume_at if persisted is not None else None
2566
- finally:
2567
- _READ_POOL_BYPASS.reset(bypass)
2585
+ return ReadTokenPoolOperations.persist_rate_limit(
2586
+ provider,
2587
+ identity,
2588
+ error,
2589
+ bypass=_READ_POOL_BYPASS,
2590
+ )
2568
2591
 
2569
- cooldown_identities = active_cooldown_identities()
2570
- _READ_TOKEN_POOL = ReadTokenPool(
2571
- repo=repo,
2572
- secrets_json=secrets_json,
2573
- default_token=default_token,
2574
- default_identity=default_identity,
2575
- request=request,
2576
- active_cooldowns=cooldown_identities,
2577
- on_rate_limit=persist_rate_limit,
2578
- )
2592
+ with ReadTokenPoolOperations.request_context(deadline, monotonic):
2593
+ cooldown_identities = active_cooldown_identities()
2594
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
2595
+ pool = ReadTokenPool(
2596
+ repo=repo,
2597
+ secrets_json=secrets_json,
2598
+ default_token=default_token,
2599
+ default_identity=default_identity,
2600
+ request=request,
2601
+ active_cooldowns=cooldown_identities,
2602
+ on_rate_limit=persist_rate_limit,
2603
+ )
2604
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
2605
+ _READ_TOKEN_POOL = pool
2579
2606
  _READ_TOKEN_POOL_REPO = repo
2580
- return _READ_TOKEN_POOL
2607
+ return pool
2581
2608
 
2582
2609
 
2583
2610
  def _reset_read_token_pool() -> None:
@@ -2593,10 +2620,13 @@ def _should_use_read_token_pool(
2593
2620
  credential_identity: str | None,
2594
2621
  read_pool: bool,
2595
2622
  target_repository: str | None = None,
2623
+ deadline: float | None = None,
2624
+ monotonic: Callable[[], float] | None = None,
2596
2625
  ) -> bool:
2597
2626
  if not read_pool or _READ_POOL_BYPASS.get():
2598
2627
  return False
2599
- pool = _get_read_token_pool()
2628
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
2629
+ pool = _get_read_token_pool() if deadline is None else _get_read_token_pool(deadline=deadline, monotonic=monotonic)
2600
2630
  if pool is None:
2601
2631
  return False
2602
2632
  if target_repository is not None and target_repository.casefold() != pool.repo.casefold():
@@ -2648,64 +2678,23 @@ def _target_repository_from_gh_arguments(arguments: Sequence[str]) -> str | None
2648
2678
  def _call_with_read_token_pool[ReadResult](
2649
2679
  resource: str,
2650
2680
  operation: Callable[[str | None, str], tuple[ReadResult, int | None]],
2681
+ *,
2682
+ deadline: float | None = None,
2683
+ monotonic: Callable[[], float] | None = None,
2651
2684
  ) -> ReadResult:
2652
2685
  """Run one read with ranked pool identities and fail over on rate limits."""
2653
- pool = _get_read_token_pool()
2654
- if pool is None:
2655
- result, _ = operation(None, _credential_identity_for_token(None))
2656
- return result
2657
-
2658
- attempted: set[str] = set()
2659
- last_rate_limit: RetryableError | None = None
2660
- while (credential := pool.select(resource, excluded=attempted)) is not None:
2661
- attempted.add(credential.secret_name)
2662
- try:
2663
- result, remaining = operation(credential.token, credential.secret_name)
2664
- except RetryableError as exc:
2665
- if not exc.is_rate_limit:
2666
- raise
2667
- pool.record_remaining(credential.secret_name, resource, exc.remaining)
2668
- pool.mark_rate_limited(credential.secret_name, exc)
2669
- last_rate_limit = exc
2670
- continue
2671
- pool.record_remaining(credential.secret_name, resource, remaining)
2672
- return result
2673
-
2674
- if last_rate_limit is not None and not pool.default_token:
2675
- raise ProviderRateLimitError(
2676
- retry_after_seconds=last_rate_limit.retry_after,
2677
- reset_timestamp=last_rate_limit.reset_timestamp,
2678
- remaining=last_rate_limit.remaining,
2679
- provider=last_rate_limit.provider or "github",
2680
- credential_identity=last_rate_limit.credential_identity,
2681
- source=last_rate_limit.source,
2682
- is_rate_limit=True,
2686
+ with ReadTokenPoolOperations.request_context(deadline, monotonic):
2687
+ pool = (
2688
+ _get_read_token_pool() if deadline is None else _get_read_token_pool(deadline=deadline, monotonic=monotonic)
2683
2689
  )
2684
- if pool.default_is_active:
2685
- raise ProviderRateLimitError(
2686
- provider="github",
2687
- credential_identity=pool.default_identity,
2688
- source="fallback",
2689
- is_rate_limit=True,
2690
+ return ReadTokenPoolOperations.run(
2691
+ resource,
2692
+ operation,
2693
+ pool,
2694
+ _credential_identity_for_token,
2695
+ deadline=deadline,
2696
+ monotonic=monotonic,
2690
2697
  )
2691
- try:
2692
- result, remaining = operation(pool.default_token or None, pool.default_identity)
2693
- except RetryableError as exc:
2694
- if not exc.is_rate_limit:
2695
- raise
2696
- pool.record_remaining(pool.default_identity, resource, exc.remaining)
2697
- pool.mark_rate_limited(pool.default_identity, exc)
2698
- raise ProviderRateLimitError(
2699
- retry_after_seconds=exc.retry_after,
2700
- reset_timestamp=exc.reset_timestamp,
2701
- remaining=exc.remaining,
2702
- provider=exc.provider or "github",
2703
- credential_identity=pool.default_identity,
2704
- source=exc.source,
2705
- is_rate_limit=True,
2706
- ) from exc
2707
- pool.record_remaining(pool.default_identity, resource, remaining)
2708
- return result
2709
2698
 
2710
2699
 
2711
2700
  def _run_readonly_gh_command(
@@ -5725,85 +5714,19 @@ class GitHubActionsProvider(GitHubPRFilesMixin, CIPlatformProvider):
5725
5714
  complete = False
5726
5715
  reasons.append(f"task-detail:{task_id_value}")
5727
5716
  detail = task
5728
- state_value = _task_value(detail, "state", "status")
5729
- if not isinstance(state_value, str) or not state_value.strip():
5730
- state_value = _task_value(task, "state", "status")
5731
- state = state_value if isinstance(state_value, str) else ""
5732
- created_at = _agent_task_lifecycle.select_valid_timestamp_alias(
5733
- detail, "created_at", "createdAt", fallback=task
5734
- )
5735
- session_records = detail.get("sessions", task.get("sessions"))
5736
- top_level_session_record = False
5737
- if not isinstance(session_records, list):
5738
- if session_records is not None:
5739
- complete = False
5740
- reasons.append(f"malformed-session:{task_id_value}")
5741
- top_level_session_record = bool(
5742
- _task_value(detail, "session_id", "sessionId")
5743
- or _agent_task_lifecycle.has_session_activity_timestamp(detail, fallback=task)
5744
- )
5745
- session_records = [detail] if top_level_session_record else []
5746
- sessions: list[AgentTaskSessionLifecycle] = []
5747
- for session in session_records:
5748
- if not isinstance(session, dict):
5749
- complete = False
5750
- reasons.append(f"malformed-session:{task_id_value}")
5751
- continue
5752
- session_id_value = _task_value(session, "session_id", "sessionId") or (
5753
- _task_value(task, "session_id", "sessionId")
5754
- if top_level_session_record
5755
- else _task_value(session, "id")
5756
- )
5757
- if not isinstance(session_id_value, str) or not session_id_value:
5758
- complete = False
5759
- reasons.append(f"missing-session-id:{task_id_value}")
5760
- session_id_value = ""
5761
- session_state_value = state if top_level_session_record else _task_value(session, "state", "status")
5762
- started_at, session_created_at, idle_at = _agent_task_lifecycle.session_lifecycle_timestamp_values(
5763
- session,
5764
- include_created=not top_level_session_record,
5765
- fallback=task if top_level_session_record else None,
5766
- )
5767
- session_head_sha = _task_value(session, "head_sha", "headSha")
5768
- session_run_id = _task_value(session, "workflow_run_id", "workflowRunId", "run_id", "runId")
5769
- session_state = session_state_value if isinstance(session_state_value, str) else ""
5770
- head_sha = session_head_sha if isinstance(session_head_sha, str) else ""
5771
- run_id = (
5772
- str(session_run_id)
5773
- if isinstance(session_run_id, (str, int)) and not isinstance(session_run_id, bool)
5774
- else ""
5775
- )
5776
- session_values = (session_state, started_at, session_created_at, idle_at, head_sha, run_id)
5777
- if not session_id_value and not any(session_values):
5778
- continue
5779
- sessions.append(
5780
- AgentTaskSessionLifecycle(
5781
- session_id=session_id_value,
5782
- state=session_state,
5783
- started_at=started_at,
5784
- idle_at=idle_at,
5785
- head_sha=head_sha,
5786
- workflow_run_id=run_id,
5787
- created_at=session_created_at,
5788
- )
5789
- )
5790
- task_head_sha = _task_value(detail, "head_sha", "headSha")
5791
- task_run_id = _task_value(detail, "workflow_run_id", "workflowRunId", "run_id", "runId")
5792
- normalized.append(
5793
- AgentTaskLifecycle(
5794
- task_id=task_id_value,
5795
- repository=repo,
5796
- pr_number=task_pr if task_pr is not None else pr_number,
5797
- head_branch=self._agent_task_branch(detail) or branch,
5798
- state=state,
5799
- created_at=created_at,
5800
- sessions=tuple(sessions),
5801
- head_sha=task_head_sha if isinstance(task_head_sha, str) else "",
5802
- workflow_run_id=str(task_run_id)
5803
- if isinstance(task_run_id, (str, int)) and not isinstance(task_run_id, bool)
5804
- else "",
5805
- )
5717
+ lifecycle, task_reasons = _agent_task_lifecycle._map_agent_task_lifecycle_detail(
5718
+ task,
5719
+ detail,
5720
+ task_id=task_id_value,
5721
+ repository=repo,
5722
+ pr_number=pr_number,
5723
+ task_pr_number=task_pr,
5724
+ head_branch=self._agent_task_branch(detail) or branch,
5806
5725
  )
5726
+ if task_reasons:
5727
+ complete = False
5728
+ reasons.extend(task_reasons)
5729
+ normalized.append(lifecycle)
5807
5730
  inventory = AgentTaskInventory(tuple(normalized), complete, tuple(dict.fromkeys(reasons)))
5808
5731
  self._agent_task_inventory_cache[key] = inventory
5809
5732
  return inventory
@@ -26,6 +26,10 @@ _POOL_SECRET_SUFFIX = "_SWICA_CLASSIC_REPO_WORKFLOW_PAT"
26
26
  _HTTP_STATUS_RE = re.compile(r"\bHTTP\s+(\d{3})\b", re.IGNORECASE)
27
27
 
28
28
 
29
+ class _ReadTokenPoolDeadlineExceeded(RuntimeError):
30
+ """Private signal that an inventory-scoped request exhausted its deadline."""
31
+
32
+
29
33
  @dataclass(frozen=True)
30
34
  class ReadCredential:
31
35
  """A validated read credential identified only by its Actions secret name."""
@@ -321,6 +325,8 @@ class ReadTokenPool:
321
325
  expired = {identity for identity, deadline in self._cooldown_deadlines.items() if deadline <= now}
322
326
  if not expired:
323
327
  return
328
+ expired_deadlines = {identity: self._cooldown_deadlines[identity] for identity in expired}
329
+ expired_rate_limited = expired & self._rate_limited
324
330
  for identity in expired:
325
331
  self._cooldown_deadlines.pop(identity, None)
326
332
  self._current_run_cooldowns.pop(identity, None)
@@ -332,10 +338,17 @@ class ReadTokenPool:
332
338
  if self.default_identity in expired:
333
339
  self._default_login = None
334
340
  self._default_login_attempted = False
335
- self._load_candidates(self._secret_entries_list)
336
- return
337
- expired_entries = [(name, token) for name, token in self._secret_entries_list if name in expired]
338
- self._load_candidates(expired_entries)
341
+ expired_entries = self._secret_entries_list
342
+ else:
343
+ expired_entries = [(name, token) for name, token in self._secret_entries_list if name in expired]
344
+ try:
345
+ self._load_candidates(expired_entries)
346
+ except _ReadTokenPoolDeadlineExceeded:
347
+ for identity, deadline in expired_deadlines.items():
348
+ self._cooldown_deadlines.setdefault(identity, deadline)
349
+ self._active_cooldowns.update(expired)
350
+ self._rate_limited.update(expired_rate_limited)
351
+ raise
339
352
 
340
353
  def _resolve_login(self, token: str, identity: str) -> str:
341
354
  if token in self._logins_by_token:
@@ -362,6 +375,8 @@ class ReadTokenPool:
362
375
  self._warn_candidate_failure(identity, stage, error)
363
376
 
364
377
  def _warn_candidate_failure(self, identity: str, stage: str, error: Exception) -> None:
378
+ if isinstance(error, _ReadTokenPoolDeadlineExceeded):
379
+ raise error.with_traceback(error.__traceback__)
365
380
  status = self._http_status(str(error))
366
381
  if status in {401, 403, 404}:
367
382
  logger.warning("Read credential %s failed %s (HTTP %d); excluding it", identity, stage, status)
@@ -0,0 +1,299 @@
1
+ """Deadline-aware execution for pooled GitHub read requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import deque
7
+ from collections.abc import Callable, Iterator
8
+ from contextlib import contextmanager
9
+ from contextvars import ContextVar
10
+ from math import floor
11
+ from typing import Any
12
+
13
+ from agentic_devtools.cli.ci.read_token_pool import ReadTokenPool, _ReadTokenPoolDeadlineExceeded
14
+ from agentic_devtools.cli.ci.retry import ProviderRateLimitError, RetryableError
15
+ from agentic_devtools.cli.shared.retry import (
16
+ DEFAULT_RATE_LIMIT_FALLBACK_DELAY,
17
+ DEFAULT_RATE_LIMIT_MAX_DELAY,
18
+ DEFAULT_RATE_LIMIT_SAFETY_MARGIN,
19
+ calculate_rate_limit_delay,
20
+ )
21
+
22
+ _REQUEST_CONTEXT: ContextVar[tuple[float | None, Callable[[], float] | None]] = ContextVar(
23
+ "github_read_pool_request_context",
24
+ default=(None, None),
25
+ )
26
+ _DEFERRED_RATE_LIMIT_PERSISTENCE: deque[Callable[[], float | None]] = deque()
27
+
28
+
29
+ class ReadTokenPoolOperations:
30
+ """Execute pooled requests while optionally enforcing an inventory deadline."""
31
+
32
+ DeadlineExpired = _ReadTokenPoolDeadlineExceeded
33
+
34
+ @staticmethod
35
+ @contextmanager
36
+ def request_context(deadline: float | None, monotonic: Callable[[], float] | None) -> Iterator[None]:
37
+ token = _REQUEST_CONTEXT.set((deadline, monotonic))
38
+ try:
39
+ yield
40
+ finally:
41
+ _REQUEST_CONTEXT.reset(token)
42
+
43
+ @staticmethod
44
+ def current_request_context() -> tuple[float | None, Callable[[], float] | None]:
45
+ return _REQUEST_CONTEXT.get()
46
+
47
+ @staticmethod
48
+ def resolve_request_context(
49
+ deadline: float | None,
50
+ monotonic: Callable[[], float] | None,
51
+ ) -> tuple[float | None, Callable[[], float] | None]:
52
+ request_deadline, request_monotonic = _REQUEST_CONTEXT.get()
53
+ return (
54
+ deadline if deadline is not None else request_deadline,
55
+ monotonic if monotonic is not None else request_monotonic,
56
+ )
57
+
58
+ @staticmethod
59
+ def persist_rate_limit(
60
+ provider: Any,
61
+ identity: str,
62
+ error: RetryableError | ProviderRateLimitError,
63
+ *,
64
+ bypass: ContextVar[bool],
65
+ observed_at: float | None = None,
66
+ ) -> float | None:
67
+ deadline, _ = _REQUEST_CONTEXT.get()
68
+ if deadline is not None:
69
+ observed_at = time.time()
70
+ _DEFERRED_RATE_LIMIT_PERSISTENCE.append(
71
+ lambda: ReadTokenPoolOperations.persist_rate_limit(
72
+ provider,
73
+ identity,
74
+ error,
75
+ bypass=bypass,
76
+ observed_at=observed_at,
77
+ )
78
+ )
79
+ retry_after = error.retry_after if isinstance(error, RetryableError) else error.retry_after_seconds
80
+ return calculate_rate_limit_delay(
81
+ retry_after_seconds=retry_after,
82
+ reset_timestamp=error.reset_timestamp,
83
+ now=observed_at,
84
+ fallback_delay=DEFAULT_RATE_LIMIT_FALLBACK_DELAY,
85
+ safety_margin=DEFAULT_RATE_LIMIT_SAFETY_MARGIN,
86
+ max_delay=DEFAULT_RATE_LIMIT_MAX_DELAY,
87
+ ).resume_at
88
+
89
+ from agentic_devtools.cli.ci.cooldown import persist_cooldown
90
+
91
+ retry_after = error.retry_after if isinstance(error, RetryableError) else error.retry_after_seconds
92
+ token = bypass.set(True)
93
+ try:
94
+ persisted = persist_cooldown(
95
+ provider,
96
+ ProviderRateLimitError(
97
+ retry_after_seconds=retry_after,
98
+ reset_timestamp=error.reset_timestamp,
99
+ remaining=error.remaining,
100
+ provider=error.provider or "github",
101
+ credential_identity=identity,
102
+ source=error.source,
103
+ is_rate_limit=error.is_rate_limit,
104
+ ),
105
+ observed_at=observed_at,
106
+ )
107
+ return persisted[1].resume_at if persisted is not None else None
108
+ finally:
109
+ bypass.reset(token)
110
+
111
+ @staticmethod
112
+ def flush_deferred_rate_limits() -> None:
113
+ if _REQUEST_CONTEXT.get()[0] is not None:
114
+ return
115
+ while True:
116
+ try:
117
+ persist = _DEFERRED_RATE_LIMIT_PERSISTENCE.popleft()
118
+ except IndexError:
119
+ return
120
+ try:
121
+ persist()
122
+ except Exception:
123
+ _DEFERRED_RATE_LIMIT_PERSISTENCE.appendleft(persist)
124
+ raise
125
+
126
+ @staticmethod
127
+ def run_request[Result](
128
+ request: Callable[[], Result],
129
+ *,
130
+ deadline: float | None,
131
+ monotonic: Callable[[], float] | None,
132
+ ) -> Result:
133
+ try:
134
+ return request()
135
+ except RetryableError:
136
+ if deadline is not None:
137
+ ReadTokenPoolOperations.request_timeout(1, deadline, monotonic)
138
+ raise
139
+
140
+ @staticmethod
141
+ def check_http_error_deadline(
142
+ deadline: float | None,
143
+ monotonic: Callable[[], float] | None,
144
+ *,
145
+ pool_managed: bool,
146
+ is_rate_limit: bool,
147
+ ) -> None:
148
+ if pool_managed and is_rate_limit:
149
+ return
150
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
151
+
152
+ @staticmethod
153
+ def _run_without_pool[ReadResult](
154
+ operation: Callable[[str | None, str], tuple[ReadResult, int | None]],
155
+ identity_for_token: Callable[[str | None], str],
156
+ deadline: float | None,
157
+ monotonic: Callable[[], float] | None,
158
+ ) -> ReadResult:
159
+ try:
160
+ result, _ = operation(None, identity_for_token(None))
161
+ return result
162
+ except RetryableError:
163
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
164
+ raise
165
+
166
+ @staticmethod
167
+ def remaining_seconds(
168
+ deadline: float | None,
169
+ monotonic: Callable[[], float] | None,
170
+ ) -> float | None:
171
+ if deadline is None:
172
+ return None
173
+ remaining = deadline - (monotonic or time.monotonic)()
174
+ if remaining <= 0:
175
+ raise ReadTokenPoolOperations.DeadlineExpired("GitHub API inventory deadline expired")
176
+ return remaining
177
+
178
+ @staticmethod
179
+ def request_timeout(
180
+ timeout_seconds: int,
181
+ deadline: float | None,
182
+ monotonic: Callable[[], float] | None,
183
+ ) -> int:
184
+ remaining = ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
185
+ if remaining is None:
186
+ return timeout_seconds
187
+ timeout = min(timeout_seconds, floor(remaining))
188
+ if timeout < 1:
189
+ raise ReadTokenPoolOperations.DeadlineExpired("GitHub API inventory deadline expired")
190
+ return timeout
191
+
192
+ @staticmethod
193
+ def run[ReadResult](
194
+ resource: str,
195
+ operation: Callable[[str | None, str], tuple[ReadResult, int | None]],
196
+ pool: ReadTokenPool | None,
197
+ identity_for_token: Callable[[str | None], str],
198
+ *,
199
+ deadline: float | None = None,
200
+ monotonic: Callable[[], float] | None = None,
201
+ ) -> ReadResult:
202
+ if pool is None:
203
+ return ReadTokenPoolOperations._run_without_pool(operation, identity_for_token, deadline, monotonic)
204
+
205
+ attempted: set[str] = set()
206
+ last_rate_limit: RetryableError | None = None
207
+ while True:
208
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
209
+ credential = pool.select(resource, excluded=attempted)
210
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
211
+ if credential is None:
212
+ break
213
+ attempted.add(credential.secret_name)
214
+ try:
215
+ result, remaining = operation(credential.token, credential.secret_name)
216
+ except RetryableError as exc:
217
+ if not exc.is_rate_limit:
218
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
219
+ raise
220
+ pool.record_remaining(credential.secret_name, resource, exc.remaining)
221
+ pool.mark_rate_limited(credential.secret_name, exc)
222
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
223
+ last_rate_limit = exc
224
+ continue
225
+ pool.record_remaining(credential.secret_name, resource, remaining)
226
+ return result
227
+
228
+ if last_rate_limit is not None and not pool.default_token:
229
+ raise ProviderRateLimitError(
230
+ retry_after_seconds=last_rate_limit.retry_after,
231
+ reset_timestamp=last_rate_limit.reset_timestamp,
232
+ remaining=last_rate_limit.remaining,
233
+ provider=last_rate_limit.provider or "github",
234
+ credential_identity=last_rate_limit.credential_identity,
235
+ source=last_rate_limit.source,
236
+ is_rate_limit=True,
237
+ )
238
+ if pool.default_is_active:
239
+ raise ProviderRateLimitError(
240
+ provider="github",
241
+ credential_identity=pool.default_identity,
242
+ source="fallback",
243
+ is_rate_limit=True,
244
+ )
245
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
246
+ try:
247
+ result, remaining = operation(pool.default_token or None, pool.default_identity)
248
+ except RetryableError as exc:
249
+ if not exc.is_rate_limit:
250
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
251
+ raise
252
+ pool.record_remaining(pool.default_identity, resource, exc.remaining)
253
+ pool.mark_rate_limited(pool.default_identity, exc)
254
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
255
+ raise ProviderRateLimitError(
256
+ retry_after_seconds=exc.retry_after,
257
+ reset_timestamp=exc.reset_timestamp,
258
+ remaining=exc.remaining,
259
+ provider=exc.provider or "github",
260
+ credential_identity=pool.default_identity,
261
+ source=exc.source,
262
+ is_rate_limit=True,
263
+ ) from exc
264
+ pool.record_remaining(pool.default_identity, resource, remaining)
265
+ return result
266
+
267
+
268
+ def _read_cooldown_value(provider: Any, *, use_writer_token: bool) -> str | None:
269
+ """Read cooldown data without nested retries when an inventory deadline is active."""
270
+ deadline, monotonic = _REQUEST_CONTEXT.get()
271
+ getter = provider.get_variable
272
+ if deadline is not None:
273
+ function = getattr(getter, "__func__", getter)
274
+ original = getattr(function, "__wrapped__", None)
275
+ if original is not None:
276
+ getter = original.__get__(provider, type(provider))
277
+ try:
278
+ from agentic_devtools.cli.ci.cooldown import COOLDOWN_VARIABLE
279
+
280
+ return getter(COOLDOWN_VARIABLE, use_writer_token=use_writer_token)
281
+ except RetryableError as exc:
282
+ if deadline is None:
283
+ raise
284
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
285
+ if exc.is_rate_limit:
286
+ raise ProviderRateLimitError(
287
+ retry_after_seconds=exc.retry_after,
288
+ reset_timestamp=exc.reset_timestamp,
289
+ remaining=exc.remaining,
290
+ provider=exc.provider,
291
+ credential_identity=exc.credential_identity,
292
+ source=exc.source,
293
+ is_rate_limit=True,
294
+ ) from exc
295
+ raise
296
+ except Exception:
297
+ if deadline is not None:
298
+ ReadTokenPoolOperations.remaining_seconds(deadline, monotonic)
299
+ raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-devtools
3
- Version: 0.2.613
3
+ Version: 0.2.615
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=YS-Bw9zYTY2rMBsUieoT2_Cvu5Pg9gNaLVIdNU0bAdQ,524
2
+ agentic_devtools/_version.py,sha256=rn1c15V5w4TsR9WAAMV4hET9UCHsIiBfWyOomDAk1UY,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
@@ -232,14 +232,15 @@ agentic_devtools/cli/ci/ado_provider.py,sha256=6FpNOEF7zeeKdYwIgN56v7F7xpUZUYQLH
232
232
  agentic_devtools/cli/ci/agent_assignment.py,sha256=xALWk47PMo2uW44kgMm6K7EXfQN5NCv3zyyzWScIAfE,46026
233
233
  agentic_devtools/cli/ci/agent_task_diagnostics.py,sha256=7kVeBKqHn3h4yOXO3sTskOW1NuRj2uMAUYuhIAXRlGE,4306
234
234
  agentic_devtools/cli/ci/agent_task_inventory.py,sha256=1JUh6v1Wcgeo7vA49D_IU5LAm1-ohT21iEllV-HYjaY,14006
235
- agentic_devtools/cli/ci/agent_task_lifecycle.py,sha256=i9ue79HXhpLhckpCLm7p281Dr-imkcUzNHxR3j77WhI,4961
235
+ agentic_devtools/cli/ci/agent_task_lifecycle.py,sha256=aIy_uTtlORd14GJ40CVI_N3TvpBhKvtGqrvyHzwm5_s,10917
236
236
  agentic_devtools/cli/ci/approver_preflight.py,sha256=uM_LF0qrf_nMyGJT8CUvdqh0comD_AAsS9Sw_9HqBTA,5865
237
237
  agentic_devtools/cli/ci/canonical_dispatch_identity.py,sha256=VmWs3eHBs6HU2IRV_ngMUzk24pZJVE1KBFahtTvGZPA,2799
238
238
  agentic_devtools/cli/ci/commands.py,sha256=REVb_t8Ei4F37JmAn57FafIwzBa_u7PNhKkJHvchx5o,27282
239
239
  agentic_devtools/cli/ci/comment_format.py,sha256=-jbcUa1AtArU95gnGyhQ1asm704j1zYAf1m1bz7WhS8,1415
240
240
  agentic_devtools/cli/ci/conflict_repair_dispatch.py,sha256=nqdRa8DWOPx4DjLVvojUlsG9LmfFiZPtNt9eWxi_NJg,6715
241
241
  agentic_devtools/cli/ci/conflict_repair_markers.py,sha256=Bs4WG2aSlSz7MJG0o84fGwn-Zx0uFe67OGVWXZLbpsc,1528
242
- agentic_devtools/cli/ci/cooldown.py,sha256=iTtMBh6W1xvfdyk4pBN-IHImvMH9IZfvi5iXuZYvw5I,13756
242
+ agentic_devtools/cli/ci/cooldown.py,sha256=yMmqQZB3wegKk8fO3mFw4sAPANKXrNJhHArrLfckB7g,10578
243
+ agentic_devtools/cli/ci/cooldown_persistence.py,sha256=YrJdiGPnAZDYZkcgMG_iV4NsNcnEwBjKs9N0iYppwTE,4129
243
244
  agentic_devtools/cli/ci/credential_roles.py,sha256=p057fFf-g7jvaCcnoqWp4MjaPLOp3VcDfxNUaNf92nE,1649
244
245
  agentic_devtools/cli/ci/critique_adjudicator.py,sha256=pUe19bKhr8HkwCqAscScXQHm48XLz5qBiyTtEMLARd8,18533
245
246
  agentic_devtools/cli/ci/critique_provider.py,sha256=tP_lu4UiwTEn9ti4pHAEiMiWmKCAkByKhAZOIwgYIqs,3551
@@ -253,7 +254,7 @@ agentic_devtools/cli/ci/exceptions.py,sha256=zpKWOgnBbHHQgAQRvdRW4VFnE_qdgqgmo8r
253
254
  agentic_devtools/cli/ci/finalization_state.py,sha256=jQm_cH0J09LCZfEUdZK2XQNZFhF7KhyilKcGZ4g8Wzc,8978
254
255
  agentic_devtools/cli/ci/github_api_error.py,sha256=SvaOqag7OrnGyFMDjbl3Mr5LkjDM4bix9kXXvL5sHsQ,2382
255
256
  agentic_devtools/cli/ci/github_pr_files.py,sha256=7QWw5adLFKMci8AtewkJfLWle3I3irF6pZ4qdhQWGU8,2697
256
- agentic_devtools/cli/ci/github_provider.py,sha256=Bgbdl9hJvN89LUWyAaJb8LvklqFcQB2eJ_1-wUq0wug,502557
257
+ agentic_devtools/cli/ci/github_provider.py,sha256=k1Ch8gTa6qliMOyTFoBxEv5KoFVuQUizNUQiKW6Yz-E,498035
257
258
  agentic_devtools/cli/ci/guards.py,sha256=MQbtBWTpwWgNlWPJ1VJL4oZA8XciKs4J1jsN28BQBfQ,59920
258
259
  agentic_devtools/cli/ci/initial_delivery_claims.py,sha256=Aze3eNjk0XrK8FwX87MGt0Mjz9RTDl66xdx45p659ic,9764
259
260
  agentic_devtools/cli/ci/job_logs.py,sha256=YUiW487uO0usSwWD-mpWrs6q7IedQuYyhhBJIc0EAfk,12772
@@ -274,7 +275,8 @@ agentic_devtools/cli/ci/provider_repository.py,sha256=0FqIbTExEFN5Xrj7eq9p6OmUke
274
275
  agentic_devtools/cli/ci/provider_reviews.py,sha256=z63kEFkBXs8kUJW-OE8tzYtEmUEtRIZPzmvFWh-KQKs,6673
275
276
  agentic_devtools/cli/ci/provider_scheduler.py,sha256=SgoQaA2mwF_EmD_OpsJUGkr2-bSkuq34uhIuZ-B_dUM,7086
276
277
  agentic_devtools/cli/ci/push_attribution.py,sha256=BcG5p1OAbdE_LrX8w7dKNHsXI2RchP4z5T6ObxOHfRI,4276
277
- agentic_devtools/cli/ci/read_token_pool.py,sha256=BRxAjEP92Nw-cpq7EJAqyv3r4PXOYCb4gYsNZxIo9KI,17833
278
+ agentic_devtools/cli/ci/read_token_pool.py,sha256=aHhPsAXaCHIThmNuzjX6H0KXYCbPubO7q88smfhXcBM,18574
279
+ agentic_devtools/cli/ci/read_token_pool_execution.py,sha256=i6LP6LLT12V6vOY91js2dFgpnDcR08yhSU4asANjw10,11575
278
280
  agentic_devtools/cli/ci/repair_accounting.py,sha256=BHMtgKGVmcziFyxbNAmRCNJzk1MDnBVr9a0Gx7CLAPM,18495
279
281
  agentic_devtools/cli/ci/repair_prompt.py,sha256=KnNyDRuTmDyDUP7M47FDPZeREmZMFlri8Yntkb38pEU,3241
280
282
  agentic_devtools/cli/ci/retry.py,sha256=aCYvnKEoqfP6lAM3Bh744uARZtl5XI1EuVsJmOBvnEU,8651
@@ -1318,8 +1320,8 @@ agentic_devtools/_bundled_skills/skills/speckit-plan/SKILL.md,sha256=qYLpzgLCIZ6
1318
1320
  agentic_devtools/_bundled_skills/skills/speckit-specify/SKILL.md,sha256=HbHRbdphMqbtNw6fL6NcYCj6sSFmUyk2S4U0bZ9p_l4,13681
1319
1321
  agentic_devtools/_bundled_skills/skills/write-github-commit-message/SKILL.md,sha256=Ep1lR8NDN117Iytj172VZgqazFiLpJ56Z7RXgaTnxR8,2626
1320
1322
  agentic_devtools/_bundled_skills/skills/write-github-commit-message/commit-types.md,sha256=bTy_t68ZD6R87rFIzq9wybTu8Wudoahb7nB9IWC4bFo,1839
1321
- agentic_devtools-0.2.613.dist-info/METADATA,sha256=zwafKMjcPJEwGkHWpNaQGsiiVcirSDStqRaLUp7J9_w,35589
1322
- agentic_devtools-0.2.613.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
1323
- agentic_devtools-0.2.613.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
1324
- agentic_devtools-0.2.613.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
1325
- agentic_devtools-0.2.613.dist-info/RECORD,,
1323
+ agentic_devtools-0.2.615.dist-info/METADATA,sha256=19HQlADjD0-el0pLulpWfRQs0Vk7XODAxOuCo9b_qZg,35589
1324
+ agentic_devtools-0.2.615.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
1325
+ agentic_devtools-0.2.615.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
1326
+ agentic_devtools-0.2.615.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
1327
+ agentic_devtools-0.2.615.dist-info/RECORD,,