Skip to content

GH-5257: feat(briefs): daily receipts digest — per-run cost receipt lines at configurable time (default 18:00) - #5258

Merged
alekspetrov merged 4 commits into
mainfrom
pilot/GH-5257
Aug 30, 2026
Merged

GH-5257: feat(briefs): daily receipts digest — per-run cost receipt lines at configurable time (default 18:00)#5258
alekspetrov merged 4 commits into
mainfrom
pilot/GH-5257

Conversation

@alekspetrov

Copy link
Copy Markdown
Collaborator

Summary

Automated PR created by Pilot for task GH-5257.

Closes #5257

Changes

GitHub Issue GH-5257: feat(briefs): daily receipts digest — per-run cost receipt lines at configurable time (default 18:00)

feat(briefs): daily receipts digest — per-run cost receipt lines at configurable time (default 18:00)

📋 PLANNED 2026-08-29 — researched (briefs subsystem seam map complete), dispatching to Pilot.

Problem

Pilot sends one scheduled daily brief (14:00 local via orchestrator.daily_brief,
cron 0 8 * * * America/New_York). There is no end-of-day receipts digest:
one line per completed execution — issue/PR ref, diff size, duration, dollar
cost — plus a day total. All the data already exists on executions rows; it's
a formatting + second-schedule feature, not new plumbing.

Blocking defect discovered during research: GetLastBriefSent(channel)
(internal/memory/store.go:5176-5194) filters brief_history by channel only,
not brief_type. Any second scheduled brief type on the same Telegram channel
makes catch-up logic read the wrong brief's last-sent timestamp (false catch-up
fires / false skips). Must be fixed as part of this task.

Design

Approach: sibling config block + lightweight fork of the scheduler idiom.
Do NOT generalize the existing briefs.Scheduler — it hardcodes
GenerateDaily() (scheduler.go:170) and BriefType: "daily"
(scheduler.go:191), and the digest content shape (flat per-execution list +
total) doesn't match briefs.Brief (Completed/InProgress/Blocked sections).
A ~150-line receipts scheduler reusing the same cron + timezone + catch-up
pattern keeps the working daily brief untouched.

1. Config (internal/config/config.go)

  • New ReceiptsDigestConfig struct mirroring DailyBriefConfig
    (config.go:196-204) minus Time (deprecated field — don't carry it over)
    and minus Content/Filters (digest has no content toggles v1):
    Enabled bool, Schedule string, Timezone string,
    Channels []BriefChannelConfig (reuse existing type, config.go:206-211).
  • Add ReceiptsDigest *ReceiptsDigestConfig \yaml:"receipts_digest"`toOrchestratorConfignext toDailyBrief` (config.go:168).
  • Defaults (config.go:565-584 block): Enabled: false,
    Schedule: "0 18 * * *", Timezone: "America/New_York" (match daily_brief
    default), empty channels.
  • configs/pilot.example.yaml: add a documented receipts_digest: example
    under orchestrator: (note: daily_brief: has no example block today —
    greenfield; adding a daily_brief: example alongside is optional/welcome).

2. Memory (internal/memory/store.go)

  • Fix: GetLastBriefSent(channel string) → add briefType string param,
    WHERE channel = ? AND brief_type = ?. Update the single existing call site
    (internal/briefs/scheduler.go:213) to pass "daily". Existing
    brief_history rows already carry brief_type = "daily" so no migration.
  • New query GetExecutionsForReceipts(query BriefQuery): like
    GetExecutionsInPeriod (store.go:2075-2125) but SELECT/Scan the full
    receipt column set — add estimated_cost_usd, files_changed, lines_added, lines_removed, task_source_adapter, task_source_issue_id (columns exist and
    are populated via internal/executor/lifecycle.go:330-332; fuller-column
    Scan pattern precedent: GetQueuedTasksForProject, store.go:2375-2380).
    Terminal statuses only (completed + failed — failed runs still cost money;
    mark them in the output). Exclude canary rows
    (COALESCE(is_canary,0)=0, same as GetBriefMetrics store.go:2281).

3. Briefs package (internal/briefs/)

New file receipts.go (+ receipts_test.go):

  • ReceiptsScheduler: cron via robfig/cron/v3, timezone load with
    UTC-fallback-and-warn (copy scheduler.go:33-37), catch-up on start using
    the fixed GetLastBriefSent(channel, "receipts"), records sends via
    RecordBriefSent with BriefType: "receipts".
  • Generation: day window in configured timezone (two-places-load-tz shape per
    generator.go:165-168), rows from GetExecutionsForReceipts.
  • Empty day → skip send entirely (no "0 runs" noise).
  • Telegram formatting (in receipts.go or formatter_receipts.go):
    • Per-run line: #5214 merged · +88 −15 · 14m · $2.75 — issue ref via the
      established idiom (strip GH- prefix from TaskID, fall back to
      TaskSourceIssueID, guard on TaskSourceAdapter == "github";
      lifecycle.go:427-438), fall back to task title when no issue number.
      Failed runs marked (e.g. ✗ failed instead of status).
    • Total line: N runs · +ΣA −ΣD · $Σ.ΣΣ.
    • Reuse formatDuration (formatter.go:100-112),
      escapeTelegramMarkdown (delivery.go:334-344) on all dynamic strings,
      and the parse-entity-error plain-text retry pattern
      (delivery.go:246-255, 349-354).
  • Delivery: reuse DeliveryService/TelegramSender seam (delivery.go:19-21)
    or accept the sender directly — whichever needs less surface; no new
    adapter code (telegramBriefAdapter, cmd/pilot/adapters.go:26-41, wraps
    SendBriefMessage already).

4. Wiring (cmd/pilot/main.go)

  • After the DailyBrief block (main.go:3775-3836): read
    cfg.Orchestrator.ReceiptsDigest, construct + Start(ctx) the receipts
    scheduler. Store nil-check same as main.go:3805. Extracting a shared
    startBriefScheduler-style helper to cut the ~60-line duplication is
    welcome but optional — do not let it grow the diff into a refactor.

Acceptance criteria

  1. orchestrator.receipts_digest.enabled: true with default schedule sends a
    Telegram digest at 18:00 configured-timezone: one line per terminal
    execution that day (issue ref, +adds −dels, duration, $cost) + total line.
  2. Empty day sends nothing.
  3. GetLastBriefSent filters by brief_type; daily-brief catch-up and
    receipts catch-up cannot cross-contaminate on a shared channel (test
    covers: both types recorded on same channel, each reads its own).
  4. Canary executions excluded from digest rows and totals.
  5. Failed runs appear, marked, with their cost counted in the total.
  6. Existing daily brief behavior unchanged (its scheduler/generator/delivery
    code paths untouched except the one GetLastBriefSent call-site arg).
  7. Table-driven tests: formatter (escaping, issue-ref fallback, failed marker,
    totals), GetExecutionsForReceipts (column completeness, canary exclusion,
    period boundaries), GetLastBriefSent type filter.
  8. configs/pilot.example.yaml documents receipts_digest.
  9. make lint && make test green.

Non-goals (v1)

  • Slack/email formatting for the digest (Telegram only; channels config shape
    supports adding later).
  • owner/repo display — Execution has only ProjectPath, no repo-name
    field; single-project deployments don't need it. Do not add columns.
  • Generalizing briefs.Scheduler into a multi-brief engine.
  • Per-run receipt on PR comments (separate future task).

Refs

  • Research: briefs seam map 2026-08-29 (session research agent; findings
    embedded above with file:line anchors).

@codecov-commenter

codecov-commenter commented Aug 29, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 41.40625% with 150 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/briefs/receipts.go 43.24% 96 Missing and 9 partials ⚠️
cmd/pilot/main.go 0.00% 33 Missing ⚠️
internal/memory/store.go 61.29% 9 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@alekspetrov

Copy link
Copy Markdown
Collaborator Author

Review — REQUEST CHANGES

Strong implementation overall: brief_type cross-contamination fix is correct and well-tested (both the store-level table cases and the behavioral guard in TestReceiptsSchedulerRunNow_DeliversAndRecordsOwnBriefType), canary exclusion matches GetBriefMetrics, failed runs counted, empty-day skip works, daily brief untouched except the one call-site arg. One blocking defect, one hunk to revert, one note.

1. BLOCKING — permanent coverage gap: runs in-flight at digest time (or created after it) are never receipted

runDigest windows on created_at ∈ [today 00:00 local, now] with a terminal-status filter (receipts.go: start := time.Date(...); GetExecutionsForReceipts WHERE clause).

  • Run created 14:00, finishes 19:00 → at the 18:00 digest it's running (excluded); tomorrow's window starts at tomorrow 00:00 on created_at (excluded). Never appears in any digest.
  • Any run created 18:00–24:00 → same permanent miss.

For a cost-accountability artifact this silently undercounts spend every day — it breaks acceptance criterion 1's intent. Fix direction (pick one):

  • (preferred) Window = since last receipts digest: Start = GetLastBriefSent("telegram", "receipts").SentAt (fallback: 24h before the previous scheduled fire), End = now, filtered on completed_at instead of created_at. Every terminal run is then receipted exactly once, and catch-up automatically reports the right rows.
  • Or keep the daily window but key it on completed_at and widen Start to the previous scheduled fire time.

This also fixes the secondary issue that a missed digest caught up next morning currently reports the wrong day's rows.

2. Revert unrelated hunk — comment corruption in SetApprovalDecision

internal/memory/store.go doc comment: AND approval_decision = '' was mangled to AND approval_decision = ” (smart-quote). Unrelated to this task; revert the hunk.

3. Note (non-blocking, follow-up OK) — no Telegram message-length guard

No cap on rows and no handling for Telegram's 4096-char limit; the parse-entity plain-text retry won't catch "message is too long". At ~40 chars/line this breaks around ~90 runs/day. Fine to defer at current volume — a simple truncate-with-"+N more" line suffices.

PR converted to draft until #1 and #2 are addressed.

@alekspetrov

Copy link
Copy Markdown
Collaborator Author

Merge conflict detected. Auto-rebase failed and the conflict surface is not limited to go.mod/go.sum — holding for manual resolution instead of closing.

Conflicted files:

  • .agent/knowledge/graph.json

@alekspetrov

Copy link
Copy Markdown
Collaborator Author

🔄 Re-adopted: branch updated (new head 1e5b0d2) while held for manual rebase — autopilot is re-entering the pipeline for fresh CI (re-adoption 1/2).

@alekspetrov

Copy link
Copy Markdown
Collaborator Author

Merge conflict detected. Auto-rebase failed and the conflict surface is not limited to go.mod/go.sum — holding for manual resolution instead of closing.

Conflicted files:

  • .agent/knowledge/graph.json

1 similar comment
@alekspetrov

Copy link
Copy Markdown
Collaborator Author

Merge conflict detected. Auto-rebase failed and the conflict surface is not limited to go.mod/go.sum — holding for manual resolution instead of closing.

Conflicted files:

  • .agent/knowledge/graph.json

@alekspetrov
alekspetrov marked this pull request as ready for review August 30, 2026 15:34
@alekspetrov

Copy link
Copy Markdown
Collaborator Author

Re-review — APPROVE (verdict; formal review blocked on same-account)

All three items from the previous round verified against commit 1e5b0d2:

  1. Window fix CONFIRMED. GetExecutionsForReceipts now filters and orders on completed_at; runDigest windows [last receipts SentAt, now) with a 24h fallback. The two-digest behavioral test (TestReceiptsSchedulerRunNow_InFlightRunAppearsInNextDigest) proves the in-flight run appears exactly once in the next digest and the already-receipted run never reappears. Verified all production failure paths stamp completed_at (ReclassifyForRearm ×3, UpdateExecutionStatus terminal branch, the direct failed UPDATEs) — no NULL-completed_at dropout for failed rows.
  2. Smart-quote hunk revertedSetApprovalDecision doc comment back to ''. Root-cause pitfall memory added (sandbox gofmt corruption) — good catch.
  3. 4096 guard shipped (bonus). Truncation keeps the total computed over ALL rows, marker line included; test covers 200-row digest.

Local verification (CI reported no checks while the PR was draft): go build ./... clean, go vet clean, go test ./internal/briefs/... ./internal/memory/... all pass.

Note (non-blocking, ms-wide): digest query End = now but RecordBriefSent stamps SentAt = time.Now() after delivery — a run completing in that sub-second gap (End, SentAt) is skipped by the next digest's >= SentAt window. Recording SentAt = End (the query bound) would close it. Not worth a round-trip now; fold into any future briefs touch.

Marked ready — autopilot may merge on green CI.

)

Adds a second, independently-scheduled Telegram-only brief listing one
line per terminal execution that day (issue ref, diff size, duration,
cost) plus a day total, delivered on its own schedule (default 18:00
America/New_York). Implemented as a lightweight ReceiptsScheduler
rather than generalizing the existing daily Scheduler/Generator, since
the flat per-execution shape doesn't fit Brief's
Completed/InProgress/Blocked sections.

Also fixes GetLastBriefSent, which only filtered by channel — a second
brief type sharing a Telegram channel would have corrupted catch-up
detection for both.
…eceipts digest (GH-5257)

Fixes staticcheck QF1012 flagged by golangci-lint --new-from-rev=origin/main.
…not created_at calendar day (GH-5261)

PR#5258 review: a run created before the 18:00 digest but still running at
send time was excluded that day (created_at bounds it in) and excluded the
next day (created_at bounds it out) — its cost never appeared in any digest.
Any run created after 18:00 had the same fate. GetExecutionsForReceipts now
filters/orders on completed_at, and runDigest windows [last digest SentAt,
now) instead of [today 00:00, now), so every terminal execution is receipted
exactly once regardless of when it started. Also reverts the SetApprovalDecision
doc-comment smart-quote corruption from the same PR, and adds the optional
Telegram 4096-char truncation guard for long digests.
@alekspetrov

Copy link
Copy Markdown
Collaborator Author

🔄 Re-adopted: branch updated (new head a6e52de) while held for manual rebase — autopilot is re-entering the pipeline for fresh CI (re-adoption 1/2).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(briefs): daily receipts digest — per-run cost receipt lines at configurable time (default 18:00)

2 participants