Durable job queue with parallel downloads; contain handler errors; workflow gates#11
Open
miridius wants to merge 79 commits into
Open
Durable job queue with parallel downloads; contain handler errors; workflow gates#11miridius wants to merge 79 commits into
miridius wants to merge 79 commits into
Conversation
The polling loop previously died permanently on any escaped handler error (telegraf's default handler rethrows), and telegraf's 90s handlerTimeout guaranteed exactly that on slow downloads - the likely cause of the historical crash. Now: - bot.catch logs and keeps polling (sets exitCode for parity); handlerTimeout raised above the worst-case handler - launch() crash exits the process so docker restarts it instead of leaving a zombie; start() resolves via onLaunch + a bounded wait for telegraf's polling field (instead of the unbounded private-field spin) - LogMessage: debounce-timer flush catches its own errors; failed initial replies are logged and retried on the next flush - handlers: root cause logged before (and independent of) the user-notification attempt; callback handler has a last-resort catch; non-Error throws render sensibly - getInfo: corrupt cache entries (incl. symlinked canonical targets) are discarded and re-scraped; cache writes are awaited but can no longer fail a successful scrape Each behavior has a test; suite is 160 green with thresholds met. Closes #6's crash-amplifier portion; remaining baseline findings tracked in #4-#10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comments claimed polling death leaves a permanent zombie; in reality the unawaited launch() rejection crashes the process and docker restarts it. Restate them as present-tense constraints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tests now cover the symlink-target discard, EEXIST tolerance, and cache-write isolation; comments that only restated test-pinned constraints are deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…description Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A BunFile that has performed a read caches its stat, so after the corrupt entry is unlinked, exists() on the same instance still reports it present and the re-scraped info is never written back. yt-dlp then fails on the missing --load-info-json file. Found by manual QA; pinned with a real-fs integration test (the unit test mocks Bun.file, which hides exactly this). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 tasks
…ables A mock encodes the author's belief about the mocked thing and can't falsify it — the corrupt-cache bug survived a fully-mocked unit test for exactly that reason. Tests now exercise real file/symlink/sparse-file semantics and real child processes; test/bin wrappers (on the dev image PATH) delegate to the real binaries unless /tmp/stub exists, so e2e and the dev bot are unaffected. Bun.spawn snapshots the env at startup, so the stubs are driven by control files rather than env vars. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-commit: fast gates, /simplify, scoped review + comment audit. Per-push: reduced e2e (hook). /pr owns QA on the dev bot and the description standards (moved out of memories/CLAUDE.md so they sit in the path of the action). /merge is the final gate: /code-review with user-approved dismissals only, full e2e (youtube included), merge, cleanup, deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The official code-review plugin is uninstalled: it shadowed the built-in, posted PR comments (banned here), and lacked --fix. Its two lenses the built-in doesn't cover (git history, CLAUDE.md/code-comment compliance) move into /merge as explicit agents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/commit: /code-review high report-only (branch-wide scope would re-apply reverted fixes if --fix were used). /pr: toolkit lenses pr-test-analyzer + silent-failure-hunter with honest coverage claims and a disposition record. /merge: /code-review max with scope pinned to main...HEAD — the default scope is empty on a pushed clean branch — and gate fixes skip /commit's review to avoid the cascade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…defs Skills carry only what would otherwise go wrong (48 lines total, was ~120); the comment and PR-description audit prompts become .claude/agents definitions loaded only when spawned. silent-failure-hunter lens dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Handlers now only persist a job file (so the telegram ack stops being the durability mechanism) and a worker pool processes up to 3 jobs in parallel, recovering queued work on boot. yt-dlp concurrency is capped at the execYtdlp layer so inline queries count against it too. LogMessage/download-video/handlers run off (telegram, me, job fields) instead of telegraf ctx. handlerTimeout drops to 5 minutes; an inline query exceeding it is logged and left to finish detached, while a timeout on the enqueue-only handlers still flags the process. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QA found restart-mid-send delivers the video twice: the job file outlives a completed send, so recovery replays it. Jobs are now renamed to .sending right before the telegram send and recovery drops marked jobs — downloads stay at-least-once, sends become at-most-once. limit() handed its slot non-atomically (a microtask-window arrival could exceed the cap); the releaser now passes the slot to the waiter directly. SIGTERM stops the queue from starting new jobs, and the e2e harness drains in-flight jobs before deregistering its mock API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… exit Settled the PR's open questions: - Send delivery is at-least-once: recovery re-runs jobs (reverting the at-most-once .sending-drop). Duplicates are minimized by prompt unlink and a 30s compose stop grace so in-flight sends finish before SIGKILL. - An unexpected processor throw retries up to 3 times (attempts counter in the job file), then drops, so a deterministic bug can't crash-loop; a failed retry-write drops cleanly rather than orphaning the job. - bot.catch no longer sets process.exitCode on contained errors, so a later graceful shutdown isn't reported as failed. - The confirmation store is unified: parked files are confirmed-job shaped, and confirming is one atomic rename into the queue (adoptJob) instead of take-then-enqueue with a restore-on-failure dance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Passing the HEAD target scopes the review to the pending change, so --fix is safe — replaces the report-only-then-apply-by-hand workaround. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-commit hook rejects any commit lacking a fresh one-shot marker, which the /commit skill creates right before committing. git runs the hook for every commit regardless of how it's typed, so unlike a text-matching PreToolUse hook it can't be evaded by `git -c x=y commit` or false-trigger on commands that merely mention the phrase. The marker is consumed before check.sh and expires after 5 min, so a stale or abandoned one can't approve an unrelated commit. Because check.sh now gates every commit, the flaky 'recovers persisted jobs in FIFO order' test had to go: run() lazily reads each job file before calling the processor, so at concurrency 3 two recovered jobs race and completion order isn't deterministic. The test now forces concurrency 1 (sequential), via a test-only resetJobQueue override, so processor-call order equals the dequeue order it means to assert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Evidence (Anthropic prompting guidance + skill-creator) shows a bare imperative gets rationalized away, while a one-line WHY naming the actual failure mode prevents the skip — which is why I kept skipping the comment-audit step. So: descriptive bloat cut, but each load-bearing step now carries a concise why, and /commit opens with 'run every step, every time'. Per review feedback: /pr drops both review lenses (pr-test-analyzer was vetoed; silent-failure-hunter was near-fully subsumed by /code-review and had a bad false positive) and the open-decisions concept (resolve via AskUserQuestion before writing the description), and pushes before creating the PR; /merge uses /code-review's default scope, never skips /code-review, gates every dismissal on the user's explicit yes, adds a comment-audit pass, and drops the redundant CLAUDE.md/history agents (/code-review already checks CLAUDE.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.claude/rules/ files load into context when I author matching files, so the policy reaches me at write time: skill-writing.md (keep a why on load-bearing steps, cut bloat) when editing skills, testing.md (mock only at unowned boundaries; real fs/process; a test per seam) when editing tests. /code-review doesn't read these, so /merge gets a compliance lens (separate change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Switch the gate from max to xhigh — max's recall mode produced ~50% false positives in an experiment (it nearly made me 'fix' two non-bugs), and a false-positive fix is worse than a real bug that surfaces in prod. Add a rules-compliance agent: /code-review's prompt has no CLAUDE.md or .claude/rules/ step (the earlier 'it already checks CLAUDE.md' was about the cloud reviewer, not the local skill), so this agent is the only thing that checks them — its findings always go to AskUserQuestion since the rule may be the wrong thing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Block raw `gh pr create` / `gh pr merge` so the skills (QA, audited description, review gate, e2e, deploy) can't be bypassed. The /pr and /merge skills authorize the real command by touching a one-shot marker the hook consumes. Scope the hook with `if` globs (`gh pr create*` / `gh pr merge*`) rather than firing on every Bash; the start-anchored sed re-validates because the matcher fires conservatively on commands containing shell substitution. No leading space before `*` — a space imposes a word boundary that a bare `gh pr create` would slip past. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
adoptJob reserved its id in `known` only after the rename made the file visible, but startJobQueue runs its recovery scan while polling is already live (bot.ts), so a callback_query→adoptJob can race the recovery readdir: it lists the renamed file, finds the id not yet known, and queues it a second time → double-send. Reserve before the rename, mirroring enqueueJob, and roll back `known` if the rename throws. Give ids a fixed-width monotonic counter between the timestamp and the uuid so same-millisecond enqueues keep submission order through the recovery name-sort (a random uuid tail reordered them). Tested. Quiet the run() "unreadable job" log for ENOENT: a missing file is a consumed job, not corruption (Bun throws SyntaxError, code undefined, for malformed JSON, so real corruption still logs). No deterministic test pins the adoptJob race itself: reproducing it needs an injected seam between the rename syscall and the JS continuation, and a non-deterministic test is a false guard (verified: the obvious Promise.all test passes with the fix reverted). The intent comment carries the invariant instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
requestConfirmation wrote a pending-download file (addPending) and then sent the inline-keyboard message whose buttons carry that file's id. If the send threw, the buttons never reached the user, so nothing could ever consume the file (takePending/adoptJob fire only from those button callbacks) and it leaked in _pending-downloads forever — there is no recovery scan or TTL over that dir. Drop the file on send failure, then rethrow so processUrlJob still reports "Download failed" to the user. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- unref the retry-backoff timer so a pending backoff doesn't hold the process open at shutdown — a stopped queue can't run the retry anyway, and the job file survives on disk for next-boot recovery. - trim the stderr cap on a line boundary so the cut can never decapitate the ERROR: prefix isPermanentError keys on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Encode the rule I kept getting wrong: a real bug is fixed (all of them, never a subset; "out-of-scope"/"pre-existing" aren't categories), never asked-about. Only empirically-refuted findings get dropped without asking — a guard you merely think covers it isn't proof. Everything else you'd decline (rare/acceptable/works-as-intended-by-inference), and any fix that changes user-SPECIFIED behavior, goes to the user. Recurring false positives get a clarifying comment/test, out-of-band. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
isPermanentError only treated YtdlpErrors as permanent, so a permanent Telegram failure (403 blocked/kicked/deactivated, or a 400 chat-not-found / PEER_ID_INVALID) on sendVideo burned all 3 attempts + backoff before dropping. Classify those as permanent so they fail fast. The 400 branch requires error_code 400 so a transient 5xx echoing the phrase stays retryable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
requestConfirmation already dropped the pending record on a failed send, but a postDownload pending owns an already-downloaded video file that was left on disk — so a long-video job whose confirmation send keeps failing (then exhausts its retries) orphaned the video forever. Mirror the cancel path: unlink the file too. The per-retry pending churn is harmless (each attempt's pending is taken before the rethrow). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cancel path took (deleted) the pending file, then on an unauthorized click re-wrote it — a window in which a concurrent confirm's adoptJob rename hit a spurious ENOENT and the confirm was wrongly lost. Peek with getPending for the auth check and takePending only once authorized; if a confirm adopted it between the peek and the take, treat it as unavailable rather than deleting the file the now-running job needs. Removes the now-dead putPending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewers keep mis-flagging two correct behaviors. (1) messageId is undefined exactly when no live reply exists, so a retry seeds nothing and posts a fresh report — not a duplicate (the only duplicate is the accepted lost-ACK). (2) doFlush needs no lock around `texts`: .map reads values synchronously, so a racing append only grows it and flushes next round. Clarify both with a terse comment and a test that pins the behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scheduledRetries always equalled retryTimers.size (add on schedule, delete
on fire, clear on reset), so it was a second source of truth that could
only ever drift. Use retryTimers.size directly in jobsIdle. Also document
that the {...job} retry write carries forward processor mutations like
logMessageId (the same object reference is handed to the processor).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the why into the code so it stops getting re-flagged: fromId's ?? 0 is dead-defensive (telegraf's NonChannel guarantees `from`); a non-ENOENT adoptJob failure leaves the claim clickable and the atomic rename enqueues at most once; bot.botInfo! is safe even if the polling-wait times out (getMe runs before onLaunch); the shared yt-dlp concurrency budget is an accepted trade-off, not a starvation bug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
doUpdate compared the copy's size+mtime before/after --update on top of the stdout check; yt-dlp reliably prints "up to date" when current (and the new version otherwise), so the stat-dance was redundant — skip on the stdout match alone. Also extract the corrupt-cache symlink-aware cleanup into removeCacheEntry, so the two-file cache layout is encoded once and reusable for future eviction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The enqueue-failure path hand-rolled a ctx.telegram.sendMessage(...).catch that re-implemented exactly what LogMessage already encapsulates (reply-to, HTML, swallow+log notify errors). Route it through LogMessage like every other failure report, so failure delivery lives in one place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the bug-war-story clause from testing.md (the rule stands on its own); tighten the NoLog super() and LogMessage flushing-field comments; remove two test comments that restated their assertion / test name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A retry timer left armed across stopJobQueue would fire into a stopped queue, stranding the id in `pending` (jobsIdle then never settles). Clear them on stop — the job file survives for next-boot recovery. Also drop the MAX_ATTEMPTS consumer-narration sentence and the rename's restating trailing comment, both flagged by the whole-PR audit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Q2: getInfo's symlink-creation EEXIST handler now replaces the colliding entry (a stale/dangling sibling symlink or a pre-aliasing regular file) instead of swallowing it, so an alias self-heals to the fresh canonical. Q3: restore doUpdate's size+mtime check — skip the swap only when stdout says "up to date" AND the copy didn't move, so a reworded "up to date" string can't silently drop a real update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A postDownload confirmation downloads the video before prompting, so downloadVideo's memoize cache holds a 'downloaded' verdict keyed on the filename while the file sits on disk awaiting the user's choice. Both cleanup paths — the prompt-send failure (requestConfirmation's catch) and the requester's cancel — unlinked that file but left the memo. A retry or a re-request of the same URL then replayed the stale 'downloaded' verdict without re-downloading, and sendVideo threw on the now-missing file; since that error isn't permanent, the job crash-looped to MAX_ATTEMPTS and the video was lost for the process lifetime. Route both cleanup paths through a shared discardConfirmedDownload helper that unlinks the file and drops the memo entry together, so neither site can drift out of lockstep again. sendVideo's own post-send unlink stays outside the helper: it writes the .id file in the same breath, so isDownloaded remains truthful and the memo verdict is still valid. Also trims three comments flagged by review (handlers enqueue-failure note, job-queue retry-persist note, two test setup notes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A URL posted in a group whose enqueue failed (e.g. disk full) got a "💥 Download failed" reply, but every other group url-job failure is silent (processUrlJob uses NoLog for groups, matching main, so the bot doesn't spam groups with errors for every non-video link someone happens to post). The enqueue-failure path was the lone exception. Gate it on a private chat so it matches the rest; groups now only see errors for confirmed downloads they opted into. Also dedupe the group-chat test fixture to one top-level `groupChat`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The durable queue and the parked-confirmation store coordinated concurrent access through raw files under /storage — atomic renames, read-then-unlink, in-place rewrites, FIFO via timestamp-counter filenames. That kept spawning races and durability gaps we patched one at a time (the take/adopt race, the non-atomic retry-persist, double-queue windows). Replace it with one embedded SQLite database (bun:sqlite — built into Bun, no dependency, no separate process, synchronous). A new src/db.ts owns the connection, the WAL pragmas, a versioned migration, a tx() helper, and a test-only resetDb(). job-queue.ts and pending-downloads.ts become thin table wrappers; the in-memory pump/concurrency/backoff layer is kept verbatim on top. What the transaction dissolves: - confirm vs cancel: both DELETE … RETURNING the same pending row, so exactly one wins — no rename/unlink race. - retry-persist: a single UPDATE replaces the non-atomic in-place file rewrite. - FIFO + recovery: rowid is the submission order; recovery is SELECT … ORDER BY id; at-least-once is a row that outlives a crash. adoptJob now takes a pending id (not a file path) and does the delete-pending + insert-job move in one transaction. isNotFound moves to src/fs-utils.ts (the blob bytes and yt-dlp temp files still live on disk). The blobs / video_info tables and blob_key columns are created now but unused — the content-addressed blob store that fills them is the next phase. Tests run against a real DB (SQLite is ours, so it isn't mocked); resetDb() replaces the per-test directory wipes, and the e2e seeds a row instead of a job file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The video files and their two caches were the last filesystem-as-database holdouts: blobs named by yt-dlp's title-based template, a per-bot `.id` sibling file for the Telegram file_id, and a `_video-info/` symlink-alias maze for the scraped info. Title/URL-derived names plus no shared ownership are what made the cross-job unlink races possible. Replace all three: - A content-addressed blob store (src/blob-store.ts): a blob's key is a hash of yt-dlp's stable video identity (extractor:id:format), known before download, so the "already have it" short-circuit survives. Bytes live at /storage/blobs/<key>.<ext>; downloadVideo renames yt-dlp's output there and records a `blobs` row. The file_id moves into that row (the `.id` file is gone); the info cache moves into a `video_info` table (the symlinks are gone). - Refcounted GC (releaseBlob): the bytes are unlinked only when no parked confirmation still references the blob_key and it has no file_id — the reference check and the row delete are one transaction, so two concurrent releases can't both unlink. This dissolves the shared-blob race at the root. - Confirmed jobs self-heal: processConfirmedJob now always calls downloadVideo (a no-op when the blob is present, a re-fetch when it was GC'd), so a blob a concurrent release dropped is re-downloaded instead of failing the send. discardDownload (release the blob + invalidate the in-process memo) runs on every abandoned-work path: a failed confirmation prompt, a cancel, a terminal job failure, and a failed inline query. The Telegram file_id is now a single column rather than per-bot-identity — correct for the single-bot deployment (dev and prod aren't meant to share the DB). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Content-addressing the blob store removed the last use of the bot username: it only ever named the per-bot `.id` file_id cache, which is now a DB column. So `me` is dead weight threaded from bot.ts through processJob into downloadVideo/sendVideo. Remove it from those signatures (and their memo-key tuples), from processJob/processUrlJob/processConfirmedJob, and from the inline handler. The bot.ts processor closure no longer reaches for bot.botInfo!.username, so the stale "recovered jobs need botInfo for file naming" comment goes too (the bounded polling-wait stays — it's there so SIGINT's bot.stop() doesn't throw before telegraf sets its polling field). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The whole-PR review surfaced a blob-GC race — a job's terminal failure could unlink bytes a concurrent job for the same video was mid-upload on — plus a few smaller edge bugs. Rather than refcount in-flight jobs, serialize them: withBlobLock(info, fn) is a per-content-key FIFO async lock, and every operation that materializes, sends, or deletes a blob's bytes (both job processors, the inline handler, and cancel's cleanup) runs under it. Concurrent same-video work takes turns: the second reuses the first's cached file_id, or re-downloads cleanly if the first failed and discarded. The download/send/release primitives stay lock-free; only those entry points acquire it (via releaseAbandoned for the out-of-lock release paths), so there is no reentrancy. Also from the audit: - sendVideo now releases an over-size video's blob — it returned without sending, leaking a multi-GB file and its row forever. discardDownload moved into download-video.ts so sendVideo can call it. - getInfo skips the DB cache for a verbose request, so /verbose actually re-scrapes and streams yt-dlp output instead of silently returning a cached row. - probeDuration returns undefined without spawning ffprobe when the file is gone (an already-uploaded blob), dropping a spurious error log. - Removed the obsolete `mutations` test-seam; the durability tests now drive real DB write failures (RAISE(FAIL) triggers) and a real EISDIR unlink rather than spying owned code. - Refreshed stale filesystem-era comments left by the SQLite rewrite. Test-run hardening (the CPU incident): check.sh and CLAUDE.md wrap the containerized `bun test` in `timeout -k 30 300`, so a hung/non-exiting run self-kills and `--rm` cleans up instead of orphaning a 100%-CPU container. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ries Whole-PR review + live-QA fixes, all settled with the owner: - Drop dead schema: the write-only video_info.webpage_url column + its index, and the never-read blobs.size column (size is re-stat'd at send time). The initial migration is unreleased (prod creates the DB fresh at merge), so it's finalized in place. - Remove the sendVideo memoize. withBlobLock serializes concurrent sends of the same video and blobs.file_id handles reuse, so the memo could only ever cache a stale result — e.g. a repeated inline query of an over-size video re-cached its `undefined` and needlessly re-downloaded the bytes. - Reject an over-size video for an inline query before downloading it: inline is for small clips, and a >2GB video can't be sent anyway. New tooLargeToSend() gates on the scraped size up front; sendVideo still gates on the real on-disk size after download (for when the estimate is missing/wrong). - Comments: the refcount is a release-guard, not a GC; refresh stale filesystem-era test comments left by the SQLite rewrite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An inline query for a video whose scraped size was missing/under the limit but whose real bytes exceed it downloaded, sendVideo discarded it and returned undefined, and the handler answered nothing. Now both the up-front estimate reject and the post-download case answer a "Video too large" inline article via a shared answerTooLarge() helper, so the user always gets feedback. Also trims two restating comments flagged by the whole-PR audit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Whole-PR review findings, fixed at the root: - job-queue: a throw in run()'s DB bookkeeping (the row read or the completion delete) escaped as an unhandled rejection and wedged the id in `known`. Catch it once at the pump's fire-and-forget boundary so the id is freed and the row re-runs on the next boot — replacing two inner try/catches with one guard at the site the rejection actually escapes. - handlers: pre-reject a too-large video from the scraped estimate before downloading (or offering to download) it in processUrlJob, and report a too-large confirmed job instead of silently dropping it on its NoLog in processConfirmedJob. sendVideo still gates on the real on-disk size. - single-source the "too large" line (tooLargeMessage) so its three chat report sites stay in sync, and document sendVideo's `undefined` = too-large return contract. - comment nits flagged by the audit (db tx; execYtdlp stderr/stdout). Tests cover the queue's vanished-row and delete-throw paths, the too-large pre-reject (private report + group silence), and the confirmed-job too-large report. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The commit gate's marker was content-free: `touch .commit-approved` let any `git commit` through once /commit had run at some point, so "fix every review finding" relied on discipline — the recurring failure mode. Make the approval deterministic and un-inheritable: - stage-id.sh: a content-bound id for the staged snapshot = HEAD + the staged tree SHA (git's own content hash of the index). Returns nonzero if the index can't be hashed (unmerged), so callers fail closed instead of minting a degraded value. - review-approve.sh: mints the approval, run by /commit's new review-attestation step ONLY after a fresh agent finds the staged change clean. Computes the id before writing, so a hashing failure leaves no marker. - commit-gate.sh: allows a commit only with a fresh approval whose hash matches the EXACT staged snapshot. A direct commit (no marker), an inherited/stale approval, or any re-stage after the mint all block. It does not claim to prove a review ran — it makes an omitted review a deliberate, visible bypass. - pr-approve.sh / pr-gate.sh: the same for /pr's merge gate — pr-gate.sh stamps all-pr-skill-steps-passed only with a whole-PR approval bound to HEAD, instead of a raw `gh api` call. - /commit and /pr SKILLs: a fresh attestation agent re-reviews the final change and mints the approval (you never mint it yourself); the gate is run, not hand-stamped. Every block/allow path was tested directly, including the unmerged-index fail-closed path; this commit itself went through the new gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- job-queue: the retry-backoff comment referenced a `run_after` column that the SQLite schema never had; say "the backoff deadline is never persisted" instead. Also pin the recurring "delete-throw → duplicate" false positive: the pump catch leaves the row to re-run, a duplicate at-least-once permits. - log-message: drop a "// unchanged" that just restated the equality guard it sat on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The skill said "don't ask; run it", and I asked anyway — by dressing a scope/skip as a question/checkpoint at the gate-clearing step. Name that pattern directly (question, recommendation, checkpoint, status-with-a-fork are all the same violation) and point at why it's futile: pr-gate.sh won't stamp without a fresh whole-PR attestation bound to HEAD, so no green-gate path skips the review. The only thing /pr ever puts to the user is a step-2 finding-triage decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ci.yml: the /storage-at-import note named download-video.ts, but the import-time writers are now db.ts (opens mp4ify.db) and blob-store.ts (mkdirs /storage/blobs). - download-video.ts: drop a "// 5 minutes" that restated 1000 * 60 * 5. - handlers.ts: pin the recurring reportJobFailure false-positive — a failed report-send leaves messageId undefined, so the retry posts fresh (no message to edit), which is correct, not a duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The e2e.sh-full download snapshots (instagram/reddit/youtube — full-only, so not exercised by the pre-push reduced set) still held the old FS title-paths (/storage/<Extractor>/<title>) and were never regenerated for the content-addressed rewrite. The downloads succeed; only the recorded `video` paths (now /storage/blobs/<sha256>) and their MockBotApi-derived file_ids needed updating. No behavior change. Also: drop a `// Cancel:`-line auth restatement (reduce to a bare `// Cancel` section marker) and a trailing "and record the blob" that restated recordBlob. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Content-addressing put the format into the blob path: the sha is sha256(extractor:id:format) and the mock's file_id is hash(blob path), so a yt-dlp format-selection shift would spuriously break the download/cache snapshots — the very drift scrub() exists to absorb. Extend scrub to normalize the blob sha (/storage/blobs/<sha>) and the derived file_id (<file_id>); the download-vs-cache snapshot shapes stay distinct, so the cache assertion holds. Regenerate accordingly. Also tighten the inline-query comment to drop a clause that restated `?.[0]`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "reports a confirmed job failure" test claimed "file missing → sendVideo throws", but processConfirmedJob runs downloadVideo before sendVideo, and the seed records no blob — so isDownloaded is false, downloadVideo re-runs, and it throws on the placeholder info (before sendVideo). Correct the comment, and the describe-block comment now names both the success and failure cases it covers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A queue-scoped reader (and review pass) keeps re-flagging adoptJob's DELETE of the pending row as a blob-ref leak: that row's blob_key is the only thing keeping a postDownload blob alive (refs count `pending` only). It's safe — processConfirmedJob re-downloads if a concurrent release dropped the bytes — but that's only visible from handlers/blob-store. Pin the cross-module why here so it stops resurfacing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
1. A latent crash-loop in the polling loop — real but possibly never fired (not observed in production; identified from the code).
handlerTimeoutdefaults to 90 s, but a download is allowed 300 s, so a slow-enough video makeshandleUpdatereject mid-flight.bot.catchregistered, telegraf's default error handler rethrows, killing the polling loop.bot.launch()'s promise is never awaited, so that becomes an unhandled rejection, which in Bun crashes the process.If it fired, docker's
restart: alwayswould revive the bot and Telegram would redeliver unacked updates, so it could pass for a blip — but a deterministic per-update error (e.g. a corrupt cache entry, also fixed here) would become a crash loop pinned on that update, which thebot.catchbelow now contains.2. Serial processing, no durability. telegraf awaits each update batch before polling again, so one slow download stalls every chat until it finishes — and the bot's only durability is Telegram's redelivery of unacked updates, which stops working the moment you stop blocking on handlers.
3. A stale yt-dlp breaks the bot for a day. Extractors rot as sites change out from under them; a broken one makes every download fail until someone notices and manually updates yt-dlp.
4. Failures are all-or-nothing. A transient network blip drops the video outright, while a genuinely-unsupported URL gets retried for nothing — and a user watching a retry sees a fresh "Downloading…" thread spawn each attempt.
5. The filesystem was being used as a database — and it raced. Job queue, parked confirmations, the URL-info cache, and video blobs were all coordinated by raw files under
/storage(_jobs/*.json,_pending-downloads/*.json,_video-info/*+ symlink aliases, title-templated blob files with sibling.idfiles). Every multi-step state change was a hand-rolled dance of atomic renames, read-then-unlink, and in-place rewrites — and each one kept producing a fresh race or durability gap (the take/adopt race, the non-atomic retry-persist, the shared-filename unlink race, the stale-memo-after-unlink bug) that we patched one at a time.6. No automated guardrails on changes. The repo had no enforced review/QA workflow, and its tests were fully mocked — so real filesystem/process/restart bugs (like #1 and #2) could ship undetected.
Fix
Error handling —
bot.catchcontains handler errors; a fatal polling crash exits logged and deliberate (docker restarts either way);LogMessage's debounce timer catches its own flush errors and failed replies retry; original errors are logged before user notification can eat them; corrupt info-cache entries are discarded and re-scraped; cache-write failures don't fail a finished scrape.One embedded SQLite database is the durable coordination store (
src/db.ts) — the job queue (jobs), parked confirmations (pending), the content-addressed blob index (blobs), and the URL-info cache (video_info) all live as tables in a singlebun:sqliteconnection (WAL,busy_timeout). bun:sqlite is built into Bun — no new dependency, no separate daemon (the server is weak), no network hop — and synchronous, so store operations are plain function calls. Every former multi-step FS dance is now one transaction, which is the race fix at the root: the take/adopt race, the non-atomic retry-persist, the shared-filename unlink race, and the stale-memo class all disappear instead of being patched. Schema is versioned byPRAGMA user_version(append-only migrations, each applied once in a transaction).Content-addressed blob store (
src/blob-store.ts) — video bytes can't live in SQLite, so they move to/storage/blobs/<sha>.<ext>keyed bysha256(extractor:id:format_id)(filename-hash fallback). The key is known from--dump-jsonbefore download, so the "we already have this" short-circuit survives; yt-dlp writes to its template path and the bytes are thenrenamed to their content-addressed home. Theblobsrow owns the path, size, and the Telegramfile_id(replacing the old.idsibling file) and is the refcount target: apendingrow referencing a blob keeps its bytes alive, andreleaseBlobdeletes the bytes only when no row references the key andfile_id IS NULL. So "do we have it?" = the row has afile_idor the bytes are still on disk; a GC'd blob has neither and is re-downloaded. That invariant makes recovery safe and structurally kills the stale-memo bug class. Two concurrent releases can't both unlink — the second sees the row already gone.Durable, parallel job queue (
src/job-queue.ts) — message handlers persist a job row and return; an in-memory worker pool processes up to 3 jobs in parallel and rebuilds its queue from thejobstable on boot, in FIFO order (ORDER BY id). yt-dlp concurrency is capped at 3 insideexecYtdlp, so inline queries count against the same budget. Semantics:file_idor bytes on disk).file_idbeing recorded) — with a 30 s composestop_grace_periodso a planned restart lets in-flight sends finish; a duplicate needs a hard crash (OOM/power) landing in those milliseconds.pendingrow; confirming it is a singletx()thatDELETE … RETURNINGs the pending row andINSERTs the job (adoptJob). Confirm and cancel bothDELETEthe same row, so exactly one wins — the old confirm→enqueue crash window is closed, and the loser cleanly reports the confirmation gone.attempts(and re-serializes the job, so a mutatedlogMessageIdsurvives) before scheduling the backoff timer; if that write fails the job is dropped rather than orphaned with a stale count. Backoff stays in-memory (a restart re-runs immediately, harmless under at-least-once).handlerTimeoutis set to 5 min and only matters for inline queries (which still download in-handler); an inline timeout is logged and left to finish detached, while a timeout on the enqueue-only handlers is logged as a real error.yt-dlp self-update (
updateYtdlp) — a background worker keeps yt-dlp fresh: it copies the binary, runs--updateon the copy, then atomicallyrenames it into place. The copy is required because the zipapp's--updaterewrites the binary in place, non-atomically — a download exec'ing it mid-rewrite would get a corrupt file; the rename means running execs keep the old inode and new execs see old-or-new, never a partial. It's single-flight and polls every 5 min so a broken extractor is fixed in minutes, not a day.Failure classification + per-attempt reporting — the queue retries transient download failures (network, 5xx, 408/429, a transient fragment 404) — up to 3 attempts (2 retries) — but fails fast on permanent ones (unsupported URL, private/removed/age-gated video, a webpage 404/410). Because the updater keeps yt-dlp current, an extractor that still can't handle a URL means it's genuinely unsupported, not stale — so retrying is pointless. Each attempt edits one message —⚠️ "retrying (attempt X of Y)…" → a terminal 💥 — instead of spawning a fresh thread.
LogMessagebecame dest-agnostic for this: url jobs log progress in private chats (aNoLogin groups, which would otherwise be spammed), and confirmed jobs report failures through the same group-capableLogMessage, so there's one send/edit/gone-message path instead of a hand-rolled copy.Tests mock only unowned boundaries — SQLite and the filesystem are ours, so they run for real:⚠️ →⚠️ →💥 in one edited message across the 3 attempts) through real SQLite persistence and the blob store.
download-video.test.tsand the queue/blob suites use a real DB (resetDb()between tests) and real child processes (test/bin/stubs on PATH, driven by control files becauseBun.spawnsnapshots the env at startup); only the Telegram API stays mocked, at thefetchboundary viaMockBotApi. The restart/recovery seam — the one testing.md names as exactly what mocks miss — gets a real e2e: a bot boots, recovers a job persisted before a crash, and delivers the video (plus a failure variant that reportsWorkflow skills (
.claude/skills/) —/commit,/pr,/mergegate every change through review, manual QA against the live bot, and a final merge check, so the green merge gate always sits on the exact commit being merged. The gate is enforced deterministically: a review-attestation step mints an approval bound (bygit write-tree+ HEAD) to the exact content reviewed, and a pre-commit / pre-merge hook fails closed when that content-bound approval is absent — so the gate can't be inherited from an earlier commit or skipped.Decisions (settled)
file_idwindow). A rare duplicate beats silently losing a video.file_id IS NULLand nothing references it. That single invariant is what makes recovery re-download a dropped blob instead of replaying a stale memo.--updatein place) is what makes a concurrent download safe during an update.ERROR:prose. Assumes the updater keeps yt-dlp current, so unsupported/extraction errors are permanent. (Group url-job failures are still silent — that's pre-existing and tracked for a separate PR.)bot.catchno longer setsprocess.exitCodeon contained errors. The bot keeps polling, so a stale per-update error shouldn't make a later graceful shutdown report failure. Fatal polling crashes stillexit(1).JOB_CONCURRENCY = 3,YTDLP_CONCURRENCY = 3,YTDLP_UPDATE_INTERVAL = 5 min,stop_grace_period = 30 s,MAX_ATTEMPTS = 3.🤖 Generated with Claude Code