Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ Operational learning: the first live News run failed because Copilot CLI was req

After enabling the Copilot PAT, one live run failed because Copilot CLI wrote JSON without the required `sections` array. The provider now treats invalid structured Copilot output as an AI-provider failure and falls back to the deterministic summary shape, recording `provider.type: copilot-cli-fallback` and the validation reason instead of failing the whole state/deploy pipeline.

Operational learning: every scheduled `News` run failed with `task: Failed to run task "pipeline:generate": exit status 1` (step exit code 201) while the Copilot CLI itself was failing. Only the transparency report tolerated a Copilot CLI outage; the curator and writer still raised on a non-zero CLI exit code, a missing output file, or unparsable JSON, which aborted the run before any state was persisted and left the site stale. All three Copilot CLI providers now degrade to their deterministic counterparts, record `provider.type: copilot-cli-fallback` with the CLI stdout/stderr as `fallbackReason`, and log a `::warning::` so the run stays green, the briefing keeps refreshing, and the underlying Copilot CLI error is visible in the workflow log and the published data.

Operational learning: the site served stale data after manual and watchdog-triggered runs because the Pages workflow relied on the cross-workflow `workflow_run` trigger, which GitHub does not fire for upstream `News` runs started by `workflow_dispatch`. The `News` workflow now dispatches the Pages deploy explicitly (`gh workflow run pages.yml`) once it has persisted fresh state, so every trigger type (scheduled cadence, manual dispatch, and the `news-watchdog` catch-up) redeploys the site. The Pages workflow keeps only the `push` and `workflow_dispatch` triggers.

Operational learning: the site went stale for most of a day because GitHub schedule jitter routinely shifts the hourly `News` cron across hour boundaries. The cadence gate evaluates the local hour at execution time, so a run intended for an odd local hour (07:00, 09:00, …) frequently lands on an even or overnight hour and skips all real work. The original `news-watchdog` could not recover from this: it only checked whether _any_ `News` run existed in the current UTC hour, and since the hourly cron always fires (even when it gate-skips in seconds), it always saw a run and never dispatched a catch-up. The watchdog now runs every hour inside the active local window (07:00–21:59, no odd-hour parity gate so jitter cannot disable it) and decides purely on freshness: it inspects recent `News` runs, treats only a successful `Generate and persist retained news state` step as an effective generation, and dispatches a `workflow_dispatch` catch-up when the newest effective generation is older than the two-hour cadence interval and no `News` run is currently queued or in progress. This keeps the two-hour spacing while making missed scheduled runs self-healing.
Expand Down
151 changes: 82 additions & 69 deletions src/wazzup/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -232,6 +233,13 @@ def generate_transparency_report(self, request: TransparencyReportRequest) -> Tr
)


def warn_copilot_fallback(stage: str, exc: Exception) -> None:
# Keep the job green but visible: a Copilot CLI outage degrades the run to
# deterministic output instead of failing the whole briefing pipeline.
message = " ".join(str(exc).split())
print(f"::warning::Copilot CLI {stage} fell back to deterministic output: {message}", file=sys.stderr)


class CopilotCliCurationProvider:
name = "copilot-cli"

Expand Down Expand Up @@ -290,37 +298,40 @@ def curate_items(self, request: CurationRequest) -> CurationResponse:
"--no-ask-user",
]
)
result = subprocess.run(command, capture_output=True, cwd=Path.cwd(), env=run_env, text=True)
if result.returncode != 0:
details = []
if result.stdout.strip():
details.append(f"stdout: {result.stdout.strip()}")
if result.stderr.strip():
details.append(f"stderr: {result.stderr.strip()}")
detail_text = "\n" + "\n".join(details) if details else ""
raise RuntimeError(
f"Copilot CLI curation failed with exit code {result.returncode}. "
"Verify COPILOT_GITHUB_TOKEN has Copilot Requests permission, "
"or use AI_PROVIDER=fake."
f"{detail_text}"
try:
result = subprocess.run(command, capture_output=True, cwd=Path.cwd(), env=run_env, text=True)
if result.returncode != 0:
details = []
if result.stdout.strip():
details.append(f"stdout: {result.stdout.strip()}")
if result.stderr.strip():
details.append(f"stderr: {result.stderr.strip()}")
detail_text = "\n" + "\n".join(details) if details else ""
raise RuntimeError(
f"Copilot CLI curation failed with exit code {result.returncode}. "
"Verify COPILOT_GITHUB_TOKEN has Copilot Requests permission, "
"or use AI_PROVIDER=fake."
f"{detail_text}"
)
if not output_path.exists():
raise RuntimeError("Copilot CLI did not write curation-output.json")
payload = json.loads(output_path.read_text(encoding="utf-8"))
selected_ids = payload.get("selectedIds")
if not isinstance(selected_ids, list) or not all(isinstance(item_id, str) for item_id in selected_ids):
raise ValueError("Curator returned invalid selectedIds")
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
warn_copilot_fallback("curation", exc)
fallback_response = FakeCurationProvider().curate_items(request)
return CurationResponse(
selected_ids=fallback_response.selected_ids,
provider={
**fallback_response.provider,
"type": "copilot-cli-fallback",
"fallbackFrom": self.name,
"fallbackReason": str(exc),
"validated": True,
},
)
if not output_path.exists():
raise RuntimeError("Copilot CLI did not write curation-output.json")
payload = json.loads(output_path.read_text(encoding="utf-8"))
selected_ids = payload.get("selectedIds")
if not isinstance(selected_ids, list) or not all(isinstance(item_id, str) for item_id in selected_ids):
fallback = FakeCurationProvider()
fallback_response = fallback.curate_items(request)
return CurationResponse(
selected_ids=fallback_response.selected_ids,
provider={
**fallback_response.provider,
"type": "copilot-cli-fallback",
"fallbackFrom": self.name,
"fallbackReason": "Curator returned invalid selectedIds",
"validated": True,
},
)
provider = {
"type": self.name,
"model": payload.get("model", self.model or "copilot-cli"),
Expand Down Expand Up @@ -395,45 +406,46 @@ def generate_structured_summary(self, request: SummaryRequest) -> SummaryRespons
"--no-ask-user",
]
)
result = subprocess.run(command, capture_output=True, cwd=Path.cwd(), env=run_env, text=True)
if result.returncode != 0:
details = []
if result.stdout.strip():
details.append(f"stdout: {result.stdout.strip()}")
if result.stderr.strip():
details.append(f"stderr: {result.stderr.strip()}")
detail_text = "\n" + "\n".join(details) if details else ""
raise RuntimeError(
f"Copilot CLI failed with exit code {result.returncode}. "
"Verify COPILOT_GITHUB_TOKEN has Copilot Requests permission, "
"or use AI_PROVIDER=fake."
f"{detail_text}"
)
if not output_path.exists():
raise RuntimeError("Copilot CLI did not write summary.json")
payload = json.loads(output_path.read_text(encoding="utf-8"))
provider = {
"type": self.name,
"model": payload.get("model", self.model or "copilot-cli"),
"agent": self.agent or None,
"promptVersion": "summary-v1",
"validated": True,
}
try:
return response_from_payload(payload, provider=provider)
except ValueError as exc:
fallback = FakeSummaryProvider().generate_structured_summary(request)
return SummaryResponse(
headline=fallback.headline,
sections=fallback.sections,
provider={
**fallback.provider,
"type": "copilot-cli-fallback",
"fallbackFrom": self.name,
"fallbackReason": str(exc),
try:
result = subprocess.run(command, capture_output=True, cwd=Path.cwd(), env=run_env, text=True)
if result.returncode != 0:
details = []
if result.stdout.strip():
details.append(f"stdout: {result.stdout.strip()}")
if result.stderr.strip():
details.append(f"stderr: {result.stderr.strip()}")
detail_text = "\n" + "\n".join(details) if details else ""
raise RuntimeError(
f"Copilot CLI failed with exit code {result.returncode}. "
"Verify COPILOT_GITHUB_TOKEN has Copilot Requests permission, "
"or use AI_PROVIDER=fake."
f"{detail_text}"
)
if not output_path.exists():
raise RuntimeError("Copilot CLI did not write summary.json")
payload = json.loads(output_path.read_text(encoding="utf-8"))
provider = {
"type": self.name,
"model": payload.get("model", self.model or "copilot-cli"),
"agent": self.agent or None,
"promptVersion": "summary-v1",
"validated": True,
},
)
}
return response_from_payload(payload, provider=provider)
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
warn_copilot_fallback("summary", exc)
fallback = FakeSummaryProvider().generate_structured_summary(request)
return SummaryResponse(
headline=fallback.headline,
sections=fallback.sections,
provider={
**fallback.provider,
"type": "copilot-cli-fallback",
"fallbackFrom": self.name,
"fallbackReason": str(exc),
"validated": True,
},
)


class CopilotCliTransparencyReportProvider:
Expand Down Expand Up @@ -519,7 +531,8 @@ def generate_transparency_report(self, request: TransparencyReportRequest) -> Tr
"validated": True,
}
return transparency_response_from_payload(payload, provider=provider)
except (RuntimeError, ValueError, json.JSONDecodeError) as exc:
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
warn_copilot_fallback("transparency report", exc)
fallback = FakeTransparencyReportProvider().generate_transparency_report(request)
return TransparencyReportResponse(
title=fallback.title,
Expand Down
64 changes: 64 additions & 0 deletions tests/test_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,38 @@ def fake_run(_command, capture_output, cwd, env, text): # type: ignore[no-untyp
self.assertIn("fallbackReason", response.provider)
self.assertTrue(response.sections[0]["bullets"])

@patch("wazzup.ai.subprocess.run")
@patch("wazzup.ai.shutil.which", return_value="/usr/bin/copilot")
def test_copilot_cli_summary_falls_back_on_runtime_failure(self, _which, run_mock) -> None: # type: ignore[no-untyped-def]
previous_token = os.environ.get("COPILOT_GITHUB_TOKEN")
os.environ["COPILOT_GITHUB_TOKEN"] = "test-token"
source = load_sources("config/sources.yml")[0]
item = parse_feed(source, Path("tests/fixtures/microsoft-security-blog.xml").read_bytes())[0]
scored = score_items([item], [source], load_app_config("config/interests.yml"), datetime(2026, 5, 6, tzinfo=UTC))
run_mock.return_value = Mock(returncode=1, stdout="failed", stderr="upstream error")
try:
response = CopilotCliSummaryProvider().generate_structured_summary(
SummaryRequest(
kind="hourly",
window_start="2026-05-06T00:00:00Z",
window_end="2026-05-06T21:00:00Z",
generated_at="2026-05-06T21:00:00Z",
timezone="Europe/Amsterdam",
summary_language="en",
items=scored,
)
)
finally:
if previous_token is None:
os.environ.pop("COPILOT_GITHUB_TOKEN", None)
else:
os.environ["COPILOT_GITHUB_TOKEN"] = previous_token

self.assertEqual("copilot-cli-fallback", response.provider["type"])
self.assertEqual("copilot-cli", response.provider["fallbackFrom"])
self.assertIn("exit code 1", response.provider["fallbackReason"])
self.assertTrue(response.sections[0]["bullets"])


class AiCurationProviderTests(unittest.TestCase):
def test_curation_provider_defaults_to_fake(self) -> None:
Expand Down Expand Up @@ -662,6 +694,38 @@ def fake_run(_command, capture_output, cwd, env, text): # type: ignore[no-untyp
self.assertIn("fallbackReason", response.provider)
self.assertTrue(response.selected_ids)

@patch("wazzup.ai.subprocess.run")
@patch("wazzup.ai.shutil.which", return_value="/usr/bin/copilot")
def test_copilot_cli_curation_falls_back_on_runtime_failure(self, _which, run_mock) -> None: # type: ignore[no-untyped-def]
previous_token = os.environ.get("COPILOT_GITHUB_TOKEN")
os.environ["COPILOT_GITHUB_TOKEN"] = "test-token"
source = load_sources("config/sources.yml")[0]
item = parse_feed(source, Path("tests/fixtures/microsoft-security-blog.xml").read_bytes())[0]
scored = score_items([item], [source], load_app_config("config/interests.yml"), datetime(2026, 5, 6, tzinfo=UTC))
run_mock.return_value = Mock(returncode=1, stdout="failed", stderr="upstream error")
try:
response = CopilotCliCurationProvider().curate_items(
CurationRequest(
kind="hourly",
window_start="2026-05-06T20:00:00Z",
window_end="2026-05-06T21:00:00Z",
generated_at="2026-05-06T21:00:00Z",
timezone="Europe/Amsterdam",
items=scored,
max_items=12,
)
)
finally:
if previous_token is None:
os.environ.pop("COPILOT_GITHUB_TOKEN", None)
else:
os.environ["COPILOT_GITHUB_TOKEN"] = previous_token

self.assertEqual("copilot-cli-fallback", response.provider["type"])
self.assertEqual("copilot-cli", response.provider["fallbackFrom"])
self.assertIn("exit code 1", response.provider["fallbackReason"])
self.assertTrue(response.selected_ids)


class AiTransparencyReportProviderTests(unittest.TestCase):
def test_transparency_provider_defaults_to_fake(self) -> None:
Expand Down