delimit-cli 4.5.13 → 4.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +9 -8
- package/bin/delimit-cli.js +179 -4
- package/bin/delimit-setup.js +46 -6
- package/gateway/ai/_compile_status.py +154 -0
- package/gateway/ai/agent_dispatch.py +41 -0
- package/gateway/ai/backends/git_health.py +175 -0
- package/gateway/ai/backends/tools_infra.py +163 -10
- package/gateway/ai/cli_contract.py +185 -0
- package/gateway/ai/daemon.py +10 -0
- package/gateway/ai/daily_digest.py +1 -2
- package/gateway/ai/delimit_daemon.py +67 -0
- package/gateway/ai/dispatch_gate.py +399 -0
- package/gateway/ai/governance.py +181 -0
- package/gateway/ai/heartbeat.py +290 -0
- package/gateway/ai/hot_reload.py +1 -2
- package/gateway/ai/led193_daemon/executor.py +9 -0
- package/gateway/ai/ledger_manager.py +90 -4
- package/gateway/ai/ledger_proof.py +127 -0
- package/gateway/ai/license.py +132 -47
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/license_core.pyi +1 -1
- package/gateway/ai/notify.py +39 -0
- package/gateway/ai/outreach_loop_daemon.py +349 -0
- package/gateway/ai/outreach_substantive.py +1437 -0
- package/gateway/ai/pro_tools.yaml +167 -0
- package/gateway/ai/reaper.py +70 -0
- package/gateway/ai/reddit_scanner.py +17 -6
- package/gateway/ai/sensing/schema.py +1 -1
- package/gateway/ai/sensing/signal_store.py +0 -1
- package/gateway/ai/server.py +5490 -1602
- package/gateway/ai/social_capability/fit_floor.py +114 -12
- package/gateway/ai/social_queue.py +166 -10
- package/gateway/ai/tdqs_lint.py +611 -0
- package/gateway/ai/tenant_auth.py +329 -0
- package/gateway/ai/tenant_data.py +339 -0
- package/gateway/ai/tenant_paths.py +150 -0
- package/gateway/ai/usage_allowlist.py +198 -0
- package/gateway/ai/workers/base.py +2 -2
- package/gateway/ai/workers/executor.py +32 -3
- package/gateway/ai/workers/outreach_drafter.py +0 -1
- package/gateway/ai/workers/pr_drafter.py +0 -1
- package/gateway/ai/x_ranker.py +12 -2
- package/gateway/core/json_schema_diff.py +25 -1
- package/lib/auth-signin.js +136 -0
- package/lib/auth-signout.js +169 -0
- package/lib/delimit-template.js +11 -0
- package/lib/migration-2092-banner.js +213 -0
- package/package.json +5 -2
- package/server.json +4 -4
- package/scripts/build-license-core.sh +0 -85
- package/scripts/security-check.sh +0 -66
- package/scripts/test-license-core-so.sh +0 -107
|
@@ -45,10 +45,19 @@ from typing import Any, Dict, Iterable, Optional, Set, Tuple
|
|
|
45
45
|
|
|
46
46
|
logger = logging.getLogger(__name__)
|
|
47
47
|
|
|
48
|
-
# Default cooldown window for topic coverage.
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
48
|
+
# Default cooldown window for topic coverage.
|
|
49
|
+
# Original founder directive (2026-05-05): 7 days to avoid spam-pattern detection.
|
|
50
|
+
# Revised 2026-05-12 (LED-1356, panel-unanimous): 7 days × cross-platform global
|
|
51
|
+
# scope was producing 57% abstention rate (115/200 recent abstentions =
|
|
52
|
+
# topic_cooldown) and effectively blocked every Delimit-relevant topic for a
|
|
53
|
+
# full week after one draft. Reduced to 48h AND scoped per-platform/per-subreddit
|
|
54
|
+
# so a Reddit/r/mcp draft no longer cools an HN draft on MCP, and a Reddit/r/mcp
|
|
55
|
+
# draft no longer cools a Reddit/r/programming draft on MCP. Different audiences,
|
|
56
|
+
# different scans.
|
|
57
|
+
DEFAULT_COOLDOWN_HOURS = 48
|
|
58
|
+
# Backwards-compat alias for any callers that still pass `cooldown_days=` —
|
|
59
|
+
# converted to hours internally. Don't add new callers using this name.
|
|
60
|
+
DEFAULT_COOLDOWN_DAYS = DEFAULT_COOLDOWN_HOURS / 24
|
|
52
61
|
|
|
53
62
|
# Default high-engagement opportunity-cost threshold. Threads above this score
|
|
54
63
|
# pass the fit floor even without keyword match, but with ``human_only=True``
|
|
@@ -179,18 +188,63 @@ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
|
179
188
|
return dt
|
|
180
189
|
|
|
181
190
|
|
|
191
|
+
def _log_entry_scope_key(entry: dict) -> str:
|
|
192
|
+
"""LED-1356 platform-scoped cooldown key. Reddit cooldowns are per-subreddit
|
|
193
|
+
(different subreddits = different audiences); HN/devto/X are global within
|
|
194
|
+
the platform. Cross-platform doesn't cool.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
- "reddit:r/<name>" for Reddit entries with a subreddit field
|
|
198
|
+
- "reddit" for Reddit entries without (legacy fallback)
|
|
199
|
+
- The platform name itself for hn / devto / x / twitter
|
|
200
|
+
- "unknown" when the entry doesn't carry a platform
|
|
201
|
+
"""
|
|
202
|
+
platform = (entry.get("platform") or "").lower()
|
|
203
|
+
if not platform:
|
|
204
|
+
return "unknown"
|
|
205
|
+
if platform == "reddit":
|
|
206
|
+
sub = (entry.get("subreddit") or "").lower()
|
|
207
|
+
if sub:
|
|
208
|
+
sub = sub.lstrip("r/")
|
|
209
|
+
return f"reddit:r/{sub}"
|
|
210
|
+
return "reddit"
|
|
211
|
+
# twitter and x are aliases for the same audience
|
|
212
|
+
if platform == "twitter":
|
|
213
|
+
return "x"
|
|
214
|
+
return platform
|
|
215
|
+
|
|
216
|
+
|
|
182
217
|
def _recent_topic_fingerprints(
|
|
183
|
-
|
|
218
|
+
cooldown_hours: float = DEFAULT_COOLDOWN_HOURS,
|
|
184
219
|
log_path: Optional[Path] = None,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
220
|
+
cooldown_days: Optional[float] = None,
|
|
221
|
+
) -> Dict[str, Set[str]]:
|
|
222
|
+
"""Return platform-scoped topic fingerprints from ``social_log.jsonl``
|
|
223
|
+
within the cooldown window.
|
|
224
|
+
|
|
225
|
+
LED-1356 changes (2026-05-12, panel-unanimous):
|
|
226
|
+
- Window default 7d → 48h.
|
|
227
|
+
- Return shape Set[str] → Dict[scope_key, Set[str]] so callers can ask
|
|
228
|
+
"what topics has THIS audience seen recently" instead of the prior
|
|
229
|
+
"global union of every topic across every platform."
|
|
230
|
+
- `cooldown_days` is preserved as backwards-compat kwarg; converted to
|
|
231
|
+
hours internally.
|
|
232
|
+
|
|
233
|
+
Callers should look up the relevant set via the dict and pass it to
|
|
234
|
+
``evaluate_fit(..., recent_topics=...)``. A target's scope is computed
|
|
235
|
+
via :func:`_log_entry_scope_key` on the target dict.
|
|
236
|
+
|
|
237
|
+
Returns dict mapping scope_key → set of canonical signal fingerprints
|
|
238
|
+
drafted on for that scope within the window.
|
|
188
239
|
"""
|
|
189
240
|
p = log_path or _social_log_path()
|
|
190
241
|
if not p.exists():
|
|
191
|
-
return
|
|
192
|
-
|
|
193
|
-
|
|
242
|
+
return {}
|
|
243
|
+
# Backwards compatibility: if cooldown_days passed, convert to hours.
|
|
244
|
+
if cooldown_days is not None:
|
|
245
|
+
cooldown_hours = float(cooldown_days) * 24.0
|
|
246
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=cooldown_hours)
|
|
247
|
+
seen: Dict[str, Set[str]] = {}
|
|
194
248
|
try:
|
|
195
249
|
with open(p, "r", encoding="utf-8") as fh:
|
|
196
250
|
for line in fh:
|
|
@@ -204,6 +258,7 @@ def _recent_topic_fingerprints(
|
|
|
204
258
|
ts = _parse_iso(entry.get("ts"))
|
|
205
259
|
if ts is None or ts < cutoff:
|
|
206
260
|
continue
|
|
261
|
+
scope_key = _log_entry_scope_key(entry)
|
|
207
262
|
# Use the post body + thread title (when present) as the
|
|
208
263
|
# topic surface. Reddit entries log the thread title
|
|
209
264
|
# separately; X entries don't have one but the post body
|
|
@@ -214,12 +269,55 @@ def _recent_topic_fingerprints(
|
|
|
214
269
|
entry.get("thread_title") or "",
|
|
215
270
|
]
|
|
216
271
|
)
|
|
217
|
-
|
|
272
|
+
fps = _extract_topic_fingerprint(blob)
|
|
273
|
+
if not fps:
|
|
274
|
+
continue
|
|
275
|
+
seen.setdefault(scope_key, set()).update(fps)
|
|
218
276
|
except OSError as exc:
|
|
219
277
|
logger.warning("fit_floor: failed to read %s: %s", p, exc)
|
|
220
278
|
return seen
|
|
221
279
|
|
|
222
280
|
|
|
281
|
+
def topics_for_scope(
|
|
282
|
+
recent_topics_dict: Dict[str, Set[str]],
|
|
283
|
+
scope_key: str,
|
|
284
|
+
) -> Set[str]:
|
|
285
|
+
"""Convenience helper: look up the topic set for a given scope.
|
|
286
|
+
|
|
287
|
+
Returns empty set if the scope has no recent activity in the window.
|
|
288
|
+
Callers should pass the result as ``evaluate_fit(..., recent_topics=...)``.
|
|
289
|
+
"""
|
|
290
|
+
return recent_topics_dict.get(scope_key, set())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def target_scope_key(target: dict) -> str:
|
|
294
|
+
"""Compute the scope key for a target dict (for cooldown lookup).
|
|
295
|
+
|
|
296
|
+
Mirror of :func:`_log_entry_scope_key` for the inbound target side.
|
|
297
|
+
Targets may carry `platform` plus (for Reddit) `subreddit` extracted
|
|
298
|
+
from the thread URL.
|
|
299
|
+
"""
|
|
300
|
+
platform = (target.get("platform") or "").lower()
|
|
301
|
+
if not platform:
|
|
302
|
+
return "unknown"
|
|
303
|
+
if platform == "reddit":
|
|
304
|
+
sub = (target.get("subreddit") or "").lower()
|
|
305
|
+
if not sub:
|
|
306
|
+
# Try to extract from canonical_url / thread_url
|
|
307
|
+
url = target.get("canonical_url") or target.get("thread_url") or ""
|
|
308
|
+
import re as _re
|
|
309
|
+
m = _re.search(r"/r/([A-Za-z0-9_]+)", url)
|
|
310
|
+
if m:
|
|
311
|
+
sub = m.group(1).lower()
|
|
312
|
+
if sub:
|
|
313
|
+
sub = sub.lstrip("r/")
|
|
314
|
+
return f"reddit:r/{sub}"
|
|
315
|
+
return "reddit"
|
|
316
|
+
if platform == "twitter":
|
|
317
|
+
return "x"
|
|
318
|
+
return platform
|
|
319
|
+
|
|
320
|
+
|
|
223
321
|
# ── Fit-floor decision ──────────────────────────────────────────────
|
|
224
322
|
|
|
225
323
|
|
|
@@ -348,6 +446,7 @@ def append_jsonl(path: Path, payload: Dict[str, Any]) -> None:
|
|
|
348
446
|
|
|
349
447
|
__all__ = [
|
|
350
448
|
"DEFAULT_COOLDOWN_DAYS",
|
|
449
|
+
"DEFAULT_COOLDOWN_HOURS",
|
|
351
450
|
"DEFAULT_HIGH_ENGAGEMENT_FLOOR",
|
|
352
451
|
"SOCIAL_LOG",
|
|
353
452
|
"DELIMIT_DOMAIN_SIGNALS",
|
|
@@ -357,4 +456,7 @@ __all__ = [
|
|
|
357
456
|
"append_jsonl",
|
|
358
457
|
"_extract_topic_fingerprint",
|
|
359
458
|
"_recent_topic_fingerprints",
|
|
459
|
+
"_log_entry_scope_key",
|
|
460
|
+
"target_scope_key",
|
|
461
|
+
"topics_for_scope",
|
|
360
462
|
]
|
|
@@ -48,6 +48,55 @@ QUEUE_FILE = Path.home() / ".delimit" / "social_scan_queue.jsonl"
|
|
|
48
48
|
DEFAULT_DEDUPE_HOURS = 24 * 7 # don't re-queue a fingerprint within 7 days
|
|
49
49
|
DEFAULT_EXPIRE_HOURS = 24 * 7 # entries older than 7 days roll to expired
|
|
50
50
|
|
|
51
|
+
# Per-platform freshness cap at claim_pending time. Reddit posts decay
|
|
52
|
+
# in comment-visibility VERY fast (Boris-Cherny LED-1335: <6h high-yield,
|
|
53
|
+
# <12h marginal, ~zero after 24h), so drafting on a 3-day-old post wastes
|
|
54
|
+
# a brand-account engagement. Other platforms (github, devto) have longer
|
|
55
|
+
# half-lives — issue threads can be relevant weeks later — so we don't
|
|
56
|
+
# apply the freshness cap there.
|
|
57
|
+
#
|
|
58
|
+
# Founder regression report 2026-05-18: drafts were being generated on
|
|
59
|
+
# posts queued 3+ days earlier because the queue is FIFO and the drafter
|
|
60
|
+
# falls behind the scanner. This filter ensures claim_pending() never
|
|
61
|
+
# returns reddit entries whose `queued_at` is more than CLAIM_FRESHNESS_HOURS
|
|
62
|
+
# old, regardless of queue position.
|
|
63
|
+
CLAIM_FRESHNESS_HOURS_BY_PLATFORM: Dict[str, int] = {
|
|
64
|
+
"reddit": 24,
|
|
65
|
+
# Phase C (2026-05-18): github targets fail ~96% of the time
|
|
66
|
+
# (historical 3256/3389 marked drafted_failed) and the queue grew
|
|
67
|
+
# to 1122 pending dominating FIFO order. 24h cap drains stale crud
|
|
68
|
+
# while preserving fresh github targets the drafter would actually
|
|
69
|
+
# process. Without this, github starves reddit/x/hn even with the
|
|
70
|
+
# round-robin claim (see CLAIM_MAX_PER_PLATFORM).
|
|
71
|
+
"github": 24,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Phase C (2026-05-18): round-robin claim_pending across platforms so a
|
|
75
|
+
# noisy platform doesn't starve quieter ones. Drafter calls claim_pending
|
|
76
|
+
# with limit=10; pre-Phase-C this returned 10 oldest entries regardless
|
|
77
|
+
# of platform, which with github=1122 pending meant 10/10 github and
|
|
78
|
+
# reddit drafts never fired. With this cap, drafter sees a balanced mix.
|
|
79
|
+
# Within-platform order is FIFO (oldest pending first) EXCEPT for
|
|
80
|
+
# platforms in CLAIM_LIFO_PLATFORMS — see below.
|
|
81
|
+
CLAIM_MAX_PER_PLATFORM: int = 3
|
|
82
|
+
|
|
83
|
+
# Phase D (2026-05-18 founder request): "we need first-poster advantage."
|
|
84
|
+
# For time-critical engagement platforms, the drafter should pick the
|
|
85
|
+
# FRESHEST pending entry, not the oldest. Reddit comment visibility decays
|
|
86
|
+
# sharply after the first 15-30 minutes of a thread (the first 5-10 visible
|
|
87
|
+
# comments capture the bulk of upvotes + clickthrough). Pre-Phase-D's
|
|
88
|
+
# within-reddit FIFO meant the drafter pulled 22-24h-old entries (near
|
|
89
|
+
# the freshness cap) instead of brand-new ones.
|
|
90
|
+
#
|
|
91
|
+
# LIFO-within-platform reverses that for the listed platforms: within the
|
|
92
|
+
# eligible bucket, sort newest queued_at first. Across platforms, round-
|
|
93
|
+
# robin still applies. Entries that get displaced by newer ones are
|
|
94
|
+
# naturally cleaned up by the freshness cap groomer.
|
|
95
|
+
#
|
|
96
|
+
# Other platforms (github, devto, etc.) keep FIFO — their content has a
|
|
97
|
+
# longer half-life and oldest-first is the right discipline there.
|
|
98
|
+
CLAIM_LIFO_PLATFORMS: set = {"reddit"}
|
|
99
|
+
|
|
51
100
|
PENDING = "pending"
|
|
52
101
|
DRAFTED = "drafted"
|
|
53
102
|
DRAFTED_FAILED = "drafted_failed"
|
|
@@ -184,23 +233,130 @@ def enqueue(target: Dict[str, Any], dedupe_hours: int = DEFAULT_DEDUPE_HOURS) ->
|
|
|
184
233
|
|
|
185
234
|
|
|
186
235
|
def claim_pending(platform: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
|
|
187
|
-
"""Return up to ``limit`` pending entries,
|
|
236
|
+
"""Return up to ``limit`` pending entries, with round-robin balancing
|
|
237
|
+
across platforms when ``platform`` is None.
|
|
188
238
|
|
|
189
239
|
Read-only — does NOT mutate state. The caller must call ``mark_drafted``
|
|
190
|
-
or ``mark_failed`` once it processes the entry.
|
|
191
|
-
|
|
240
|
+
or ``mark_failed`` once it processes the entry.
|
|
241
|
+
|
|
242
|
+
Round-robin (Phase C, 2026-05-18): without a platform filter, returns
|
|
243
|
+
at most CLAIM_MAX_PER_PLATFORM entries from any single platform per
|
|
244
|
+
call. Within each platform, oldest-first (FIFO). Across platforms,
|
|
245
|
+
interleaved so the drafter sees a balanced mix instead of saturating
|
|
246
|
+
on whichever platform has the deepest backlog.
|
|
247
|
+
|
|
248
|
+
With an explicit ``platform`` filter, behaves as FIFO over that single
|
|
249
|
+
platform's pending entries (no per-platform cap, since the caller is
|
|
250
|
+
already targeting one platform).
|
|
251
|
+
|
|
252
|
+
Freshness cap (Phase A, 2026-05-18): entries whose platform has a
|
|
253
|
+
CLAIM_FRESHNESS_HOURS_BY_PLATFORM cap and whose ``queued_at`` is
|
|
254
|
+
older than that cap are skipped silently. Those stale entries stay
|
|
255
|
+
in the file with status=pending; the separate ``expire_stale_for_
|
|
256
|
+
freshness_caps`` pass flips them so they don't pile up forever.
|
|
192
257
|
"""
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
258
|
+
now = datetime.now(timezone.utc)
|
|
259
|
+
|
|
260
|
+
def _is_eligible(entry: Dict[str, Any]) -> bool:
|
|
196
261
|
if entry.get("status") != PENDING:
|
|
197
|
-
|
|
262
|
+
return False
|
|
198
263
|
if platform and entry.get("platform") != platform:
|
|
264
|
+
return False
|
|
265
|
+
cap = CLAIM_FRESHNESS_HOURS_BY_PLATFORM.get(entry.get("platform"))
|
|
266
|
+
if cap is not None:
|
|
267
|
+
qts = _parse_iso(entry.get("queued_at"))
|
|
268
|
+
if qts is not None and (now - qts) > timedelta(hours=cap):
|
|
269
|
+
return False
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
# Single-platform filter path: keep legacy strict-FIFO behavior.
|
|
273
|
+
if platform:
|
|
274
|
+
out: List[Dict[str, Any]] = []
|
|
275
|
+
for entry in _iter_entries():
|
|
276
|
+
if not _is_eligible(entry):
|
|
277
|
+
continue
|
|
278
|
+
out.append(entry)
|
|
279
|
+
if len(out) >= limit:
|
|
280
|
+
break
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
# Round-robin path: group by platform first (preserving within-platform
|
|
284
|
+
# FIFO via iteration order), then cap per-platform and interleave.
|
|
285
|
+
# CRITICAL Phase D change: for CLAIM_LIFO_PLATFORMS, collect the full
|
|
286
|
+
# eligible set per platform first, then sort newest-first BEFORE
|
|
287
|
+
# truncation — otherwise the early-break truncation in the FIFO path
|
|
288
|
+
# would keep the oldest entries even when we want the newest.
|
|
289
|
+
by_platform: Dict[str, List[Dict[str, Any]]] = {}
|
|
290
|
+
for entry in _iter_entries():
|
|
291
|
+
if not _is_eligible(entry):
|
|
199
292
|
continue
|
|
200
|
-
|
|
201
|
-
|
|
293
|
+
plat = entry.get("platform") or "unknown"
|
|
294
|
+
by_platform.setdefault(plat, []).append(entry)
|
|
295
|
+
|
|
296
|
+
# Sort + truncate each bucket.
|
|
297
|
+
for plat in list(by_platform.keys()):
|
|
298
|
+
if plat in CLAIM_LIFO_PLATFORMS:
|
|
299
|
+
# Newest queued_at first. Parse-failures sort last so a
|
|
300
|
+
# corrupted-timestamp entry doesn't block legitimate fresh
|
|
301
|
+
# entries from being claimed.
|
|
302
|
+
by_platform[plat].sort(
|
|
303
|
+
key=lambda e: _parse_iso(e.get("queued_at")) or datetime.min.replace(tzinfo=timezone.utc),
|
|
304
|
+
reverse=True,
|
|
305
|
+
)
|
|
306
|
+
# FIFO platforms keep insertion order (which is JSONL-append =
|
|
307
|
+
# oldest first); no sort needed.
|
|
308
|
+
by_platform[plat] = by_platform[plat][:CLAIM_MAX_PER_PLATFORM]
|
|
309
|
+
|
|
310
|
+
# Interleave: round-robin across platforms in alphabetical order for
|
|
311
|
+
# determinism. Stop when limit is reached or all buckets are drained.
|
|
312
|
+
out2: List[Dict[str, Any]] = []
|
|
313
|
+
plat_order = sorted(by_platform.keys())
|
|
314
|
+
idx = {p: 0 for p in plat_order}
|
|
315
|
+
while len(out2) < limit:
|
|
316
|
+
added = False
|
|
317
|
+
for p in plat_order:
|
|
318
|
+
if idx[p] < len(by_platform[p]):
|
|
319
|
+
out2.append(by_platform[p][idx[p]])
|
|
320
|
+
idx[p] += 1
|
|
321
|
+
added = True
|
|
322
|
+
if len(out2) >= limit:
|
|
323
|
+
break
|
|
324
|
+
if not added:
|
|
202
325
|
break
|
|
203
|
-
return
|
|
326
|
+
return out2
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def expire_stale_for_freshness_caps() -> Dict[str, int]:
|
|
330
|
+
"""Roll pending entries past their platform's claim freshness cap to expired.
|
|
331
|
+
|
|
332
|
+
Companion to ``claim_pending``'s in-flight skip: without this, the
|
|
333
|
+
queue file fills up with pending-but-permanently-skipped entries
|
|
334
|
+
that we still re-scan on every claim. Returns a dict
|
|
335
|
+
``{platform: count_expired}`` for observability.
|
|
336
|
+
"""
|
|
337
|
+
now = datetime.now(timezone.utc)
|
|
338
|
+
entries = list(_iter_entries())
|
|
339
|
+
if not entries:
|
|
340
|
+
return {}
|
|
341
|
+
flipped: Dict[str, int] = {}
|
|
342
|
+
changed = False
|
|
343
|
+
for entry in entries:
|
|
344
|
+
if entry.get("status") != PENDING:
|
|
345
|
+
continue
|
|
346
|
+
plat = entry.get("platform")
|
|
347
|
+
cap = CLAIM_FRESHNESS_HOURS_BY_PLATFORM.get(plat)
|
|
348
|
+
if cap is None:
|
|
349
|
+
continue
|
|
350
|
+
qts = _parse_iso(entry.get("queued_at"))
|
|
351
|
+
if qts is None or (now - qts) <= timedelta(hours=cap):
|
|
352
|
+
continue
|
|
353
|
+
entry["status"] = EXPIRED
|
|
354
|
+
entry["error"] = f"expired_freshness_cap_{cap}h"
|
|
355
|
+
flipped[plat] = flipped.get(plat, 0) + 1
|
|
356
|
+
changed = True
|
|
357
|
+
if changed:
|
|
358
|
+
_atomic_rewrite(entries)
|
|
359
|
+
return flipped
|
|
204
360
|
|
|
205
361
|
|
|
206
362
|
def _update_entry(fingerprint: str, mutator) -> bool:
|