Phase 7 — Batch¶
Status: Implemented (P7.1–P7.4)
ROADMAP tasks: P7.1, P7.2, P7.3, P7.4
ADRs: docs/adr/0012-idempotency-and-resumable-runs.md, docs/adr/0015-structlog-logging.md, docs/adr/0011-content-addressed-assets.md
Scope¶
Generate one consistent thumbnail per playlist item using a picked hero as the style reference, with idempotent, resumable, interruptible runs and the runs management commands.
- P7.1
core/services/batch.py(BatchService) — idempotency keys, semaphore, per-item pipeline. - P7.2 resume / cancel / SIGINT handling.
- P7.3
cli/batch.py— Rich progress, summary table, exit6. - P7.4
cli/runs.py—runs list|show|resume|cancel|delete.
Non-goals¶
- Cross-machine or multi-process runs; the DB is single-user and the
running-staleness rule is the only recovery from a crashed process. - Scheduling / daemon mode.
- Re-fetching playlist metadata inside
batch(runfetch --refreshfirst;batchuses stored items).
Interfaces¶
class BatchService:
async def run(self, spec: BatchSpec, *, progress: ProgressSink, cancel: asyncio.Event) -> RunResult
# BatchSpec(playlist_id, hero: Run | Iteration, template_ref, provider_key, concurrency, only: set[int] | None,
# dry_run, resume_run_id, reference: Literal["final","raw"], max_images, max_retries)
def plan(self, spec: BatchSpec) -> BatchPlan # per item: key, action ∈ {skip, retry, create}, reason
async def resume(self, run_id: str, *, progress: ProgressSink, cancel=None, concurrency=None) -> RunResult
def cancel(self, run_id: str) -> None # refuses a completed run
Hero → batch link (PLAN.md §3.1): batch <playlist> --hero <run|iteration> creates run(kind='batch', playlist_id=P, parent_run_id=<hero run id>, reference_asset_id=<picked iteration>.final_asset_id). Passing --reference raw uses raw_asset_id instead. Each batch iteration receives the reference asset's absolute path in GenerationRequest.reference_images.
Idempotency key (PLAN.md §6, verbatim): sha256(template.spec_hash + provider_profile.id + video.youtube_id + str(part_number) + rendered_prompt + str(seed) + reference_asset.sha256)[:32], stored on iteration.idempotency_key UNIQUE.
Batch / resume algorithm (PLAN.md §6, verbatim): for every selected playlist_item, compute the key. If an iteration with that key exists and is completed → skip (counted as done). If running and started_at is older than batch.stale_after_s (default 900 s) → treat as failed. If failed → retry while provider_response_json.attempts < --max-retries (default 2). Otherwise create a pending iteration.
Concurrency: asyncio.Semaphore(min(--concurrency, capabilities.max_concurrency)); Antigravity is therefore serialised.
Interrupt: SIGINT → cancel in-flight tasks, mark those iterations failed with error_text = "interrupted", mark run paused, exit 130. Finished iterations are never touched.
Partial completion: run ends with ≥1 failed and ≥1 completed → run failed, exit 6, message shows the resume command. All failed → run failed, exit 4.
Budget guard: --max-images N aborts before creating more than N new iterations (pre-flight count, exit 2).
State machine: pending → running → {completed, failed, cancelled, paused}; paused/failed → running via runs resume; pending → cancelled via runs cancel.
Commands (PLAN.md §5.2):
| Command | Key flags | Output | Exit |
|---|---|---|---|
thumbforge batch <playlist> |
--hero <run\|iteration>, --template NAME, --provider KEY, --concurrency 2, --only 3,7-9, --dry-run, --resume RUN_ID, --reference final\|raw, --max-images N, --max-retries 2 |
batch run; Rich progress; summary table | 0, 3, 4, 6, 130 |
thumbforge runs list |
--kind, --status, --limit |
table | 0 |
thumbforge runs show <run> |
panel + iterations table | 0, 3 | |
thumbforge runs resume <run> |
--concurrency |
continues a paused/failed run |
0, 3, 4, 6 |
thumbforge runs cancel <run> |
marks cancelled (only if not completed) |
0, 3 | |
thumbforge runs delete <run> |
--assets |
deletes run + iterations; --assets also unlinks unreferenced assets; refuses if referenced by a batch (exit 2) |
0, 2, 3 |
Behaviour¶
batch P --hero Hvalidates: playlist exists (3), hero run/iteration exists (3), hero has a picked iteration orHis an iteration id (2 otherwise), reference asset file verifies (AssetStore.verify, else 1).--only 3,7-9selects bypart_number; items withpart_number NULLare skipped unless listed explicitly by position.plan()is computed before any provider call;--dry-runprints it as a table (part, title, key[:8], action, reason) and exits0.--max-imagescompares against the count ofcreate+retryrows.- Per item: render prompt with
part_number/part_label→GenerationRequestwithreference_images=(ref_path,)→ provider (Phase 3 retries) →finalize(Phase 5render_final, injected as in P6.1) → raw and final asset rows → iterationcompleted. When the report is notok, the final asset is still stored withcompliant = 0andcompliance_report_json, and the iteration is markedfailedwitherror_textfrom the report'sviolations, so resume retries it and the roll-up counts it as an ordinary failure (exit6or4; batch has no exit5). Every step is logged withrun_id,iteration_id,providerbound in contextvars. - Progress (stdout) shows
Part n/N <title>perPLAN.md§5.3; logs (stderr) never interleave with it. In--jsonmode, progress is suppressed and a single summary object is printed at the end. runs resume RreloadsBatchSpecfromrun.params_json, applies the resume algorithm, and continues;--concurrencymay override.runs resumeon acompletedorcancelledrun exits2.runs cancel Ronpending/running/paused/failedsetscancelled; onrunningit also sets a cancel flag file<state_dir>/runs/<R>.cancelpolled by the live process, which then behaves as SIGINT but recordscancelledinstead ofpaused.runs delete Rrefuses while any run hasparent_run_id = Ror references one of its assets (ON DELETE RESTRICTsurfaces as exit2with the dependent run ids);--assetsunlinks asset files no other row references.- Summary table at the end:
part, title, status, size, compliant, asset, error; thenResume with: thumbforge runs resume <id>when exit is6or130.
Acceptance criteria¶
thumbforge batch PLxxxx --hero <hero run> --template series-parts --provider fakeon the 12-item fixture produces 12completediterations, runcompleted, exit0; every iteration'sprovider_request_json.reference_images[0]is the hero's final asset path.- Re-running the identical command creates no new iterations (all 12
skip), makes zero provider calls, and exits0. - With
--var fail=[[FAIL_TRANSIENT]]interpolated on parts 3 and 7 only (template{% if part_number in (3,7) %}), the run endsfailed, exit6, summary shows 10 completed / 2 failed, and stderr shows 3 retry attempts per failed item. runs resume <that run>after removing the failing var completes the 2 remaining items and exits0; the 10 earlier iterations are untouched (updated_atunchanged).- Sending SIGINT during a FakeProvider
delay_ms=2000run with--concurrency 1leaves the runpaused, the in-flight iterationfailedwitherror_text = "interrupted", prints the message fromPLAN.md§5.3, exit130. - An iteration left
runningwithstarted_at1000 s ago is retried byruns resumewhenstale_after_s = 900. --max-images 5on a 12-item playlist exits2before any provider call;--dry-runprints 12createrows and exits0.--concurrency 4 --provider antigravityruns strictly one provider process at a time (asserted with a stubagythat records overlapping start/end timestamps).runs delete <hero run>while the batch run exists exits2naming the batch run id.--reference rawstoresreference_asset_id = <picked>.raw_asset_id.
Decisions made in P7.1¶
The service lands before its command (P7.3), so these are where the spec was silent or the code had to choose:
BatchService.run(spec, *, progress)takes nocancelevent yet andBatchSpechas noresume_run_idorvars(P7.2, P7.3).herois a run or iteration id;BatchSpecalso carriesprovider_params.plan(spec)isasync, because a key needs the provider's identity; the only row a plan writes is the provider profile.--max-imagesis checked inrunagainst thecreate+retryrows, before the run row exists, and raisesUsageError(exit2). A dry run ignores it: it returns the plan so the user can see what to narrow.- Every non-dry run writes a batch run, also when every item is skipped (it then has no iterations of its own). A retried item is reused in place: its key is unique, so it stays in the iteration (and run) it was created in, and
BatchResult.iteration_idslists the iterations this batch ran. - Selection: without
--only, the items that have apart_number(playlist renumber --skipclears it on purpose). With--only, an item is selected when itspart_numberis listed, or when it has none and its playlist position is listed. Selecting nothing is aUsageError. - Retry budget:
provider_response_json.attemptsis cumulative over all tries of an iteration, and a failed iteration is retried while it is< max_retries. A transient failure uses its three provider calls in one try, so it is not retried automatically; a permanent one is retried once with the default of 2. - A
runningiteration with the same key is skipped as "running in another run". Staleness (batch.stale_after_s) belongs to P7.2. - The roll-up reports the playlist, not just the batch's own work: a skipped
completeditem counts as completed and a skipped failed item whose tries are used up counts as failed. Exit0when nothing failed,4when nothing completed, else6; a batch has no exit5. - No seed is sent: the key's
seedandpart_numberparts are empty strings when absent, as in hero keys. A provider that cannot take a reference image runs prompt-only and the result says so (reference_ignored). - The per-item pipeline is
hero.run_iteration;TemplateRenderer.rendertakes keyword-onlypart_numberandpart_label.
Decisions made in P7.2¶
The commands that call these (batch, runs resume|cancel) are P7.3 and P7.4, so P7.2 is service behaviour only and adds no CLI.
run(spec, *, progress, cancel=None)takes thecancelevent, optional so callers that never interrupt need not make one.resume(run_id, *, progress, cancel=None, concurrency=None)replacesBatchSpec.resume_run_id: the spec is rebuilt from the stored run, so the field would only repeat it.cancel(run_id)is synchronous.- Interrupt is
cancel.set()or cancelling the task that runsrun, which is what Ctrl-C does underasyncio.run. Both pause the run (error_text = "interrupted"). An iteration past the semaphore endsfailedwitherror_text = "interrupted"and the try is not charged againstmax_retries(an interrupt is not a provider failure, and two Ctrl-Cs must not exhaust an item); one still waiting for the semaphore stayspending; finished ones are untouched.BatchResult.exit_codeis130for a paused result andBatchResult.pendingcounts what is left, for theInterrupted: … paused (7 completed, 1 failed, 4 pending)line. The task-cancel path raisesCancelledErrorafter pausing, as asyncio requires; mapping it to exit130is the CLI's. Tests drive both paths; a real OS signal is not raised, because forwarding one is the CLI's (P7.3). - Resume continues the run in place (P7.1 review Should 1). It plans the run's spec again and retries only the iterations that run itself created: no new run, no new iteration, other runs' iterations untouched. The run is reopened (
running, end time and reason cleared) and endscompletedorfailedfrom its own iterations, so a retried item no longer leaves afailedrun behind. A run with every try used up endsfailedagain without a provider call.completedandcancelledruns, and runs that are not batch runs, are refused (exit2); an unknown run is exit3. Arunningrun is resumable: it is what a crashed process leaves.max_imagesis not applied again: a resume creates nothing new. A plainbatchre-run retries unfinished and failed items in the run that created them (their key is unique, so there is no other place) and leaves that run's status as it was, whetherfailed,pausedorcancelled, while the re-run itself has no iterations of its own; P7.3 decided what the command does with that (below). - A run stores the iteration its reference image came from (
params_json.hero), not the run id the user typed, so moving the pick on the hero run cannot change a resume's keys and regenerate finished items. - Staleness:
BatchService(stale_after_s=…)(the command passesbatch.stale_after_s; the default is 900, asmax_retriesdefaults to 2). The plan treats arunningiteration older than that, or one with no start time, asfailed(reasonstale, …, retried withinmax_retries); a younger one is skipped asrunning in another run. cancelrefuses acompletedrun (exit2), does nothing to acancelledone, and otherwise marks the run and itspending/runningiterationscancelled; finished iterations stay. It does not stop a live process: the cancel flag file is P7.4.- Counts and status are read back from the stored iterations of the run's scope (the whole selection for
run, the run's own iterations forresume), which is what lets a paused run report them. A freshrunningiteration owned by a live process counts as neither completed nor failed, as in P7.1.
Decisions made in P7.3¶
- The command is the bare
thumbforge batch <playlist> --hero <run|iteration>;--herois required, because the reference image is what makes the thumbnails a series. Flags:--template,--provider(both default from[general]),--concurrency(default[batch] concurrency),--only 3,7-9,--max-images N,--dry-run,--reference final|raw(defaultfinal). Left off the table above on purpose:--resume, whichruns resumereplaces (P7.2), and--max-retries, which comes from[batch] max_retries; a flag would be a second way to set the same knob.[batch] stale_after_sgoes to the service.--onlyis parsed by the command: part numbers1to10000, ranges ascending, anything else is exit2. - A plain
batchre-run is a supported way to continue, and the only one untilruns resumelands (P7.4). It retries failed and unfinished items inside the runs that created them (no iteration row is added), writes a run of its own that endscompletedorfailedfrom the whole selection, and leaves the older run's stored status as it was (pausedstayspaused). Closing that older run is whatruns resume <id>is for. The line after a partial, all-failed or interrupted batch therefore says to run the same command again, and namesruns show <id>; P7.4 addsruns resume <id>to it. Nothing was changed in the service for this. - Exit codes come from
BatchResult.error(), after the output is printed:4ProviderError("all N items failed", or "no item completed, F of N failed" when some are neither),6PartialBatchError("C of N items completed, F failed"),130the newRunInterruptedError, codeinterrupted("run R paused (7 completed, 1 failed, 4 pending)"). A dry run is0.3and2are the service's usual errors, raised before any run exists. - Output. stdout is a header, then the summary table
Part, Title, Status, Size, Compliant, Asset, Error, one row per selected item in playlist order, including items skipped because they are alreadycompleted. It is read back from the stored rows, so an interrupted run shows itspendingitems.--jsonprints one object:run(the same objectruns showprints),summary(total,completed,failed,pending),items(part,position,video,title,action,iteration_id,status,compliant,final_asset,error),reference_ignored,exit_code.--dry-runprints the tablePart, Title, Key, Action, Reason(key shortened to 8 characters) and--jsongivesdry_run,summary(skip,retry,create),plan(the same fields with the full key) andexit_code. - Progress is a Rich bar on stderr plus one
✔ Part 3 Title(or✘, with the error) line per finished item. It is drawn only when stderr is a terminal, and never with--jsonor--dry-run. While it is drawn the per-iteration log lines drop to DEBUG (the log file still gets them), so they cannot tear the bar. The bar's count is items done of items to generate; the line saysPart 3, notPart 3/12as thePLAN.md§5.3 sketch does, because under--onlya part number over the number of selected items reads wrongly. - Ctrl-C sets the service's cancel event: the command installs a SIGINT handler for the run, in the main thread only, and puts the previous handler back afterwards. It is
signal.signal, not the loop'sadd_signal_handler, which Windows lacks. The run pauses as P7.2 specifies, the result is printed, and the command exits130. A second Ctrl-C only sets the same event again; there is no hard stop. The test raises a realSIGINTfrom inside the fake provider's call withsignal.raise_signal. - Shared with
thumb:LogProgressandfinalizermoved tocli/_runs.py,run_payloadwas extracted fromrun_view, andRunRepository.iterations_by_keyreturns the rows the table is drawn from.
Decisions made in P7.4¶
runs listprints the newest runs first (Run, Kind, Status, Template, Provider, Done, Failed;Doneiscompleted/total) and takes--kind,--statusand--limit(default 50).--jsonprints{"runs": [...]}; each entry hasid,kind,status,template,provider,video,parent_run_id,started_at,finished_atandcounts(total,completed,failed,pending, the shapebatchprints assummary).runs showkeeps its output and adds, for every run,summary(the same counts) andrun.playlist(the playlist's YouTube id) to--json; a batch run's header also showsPlaylistandItems(2 completed, 1 failed, 2 pending of 5).runs resume R [--concurrency N]wrapsBatchService.resumewith the progress display and the Ctrl-C handlingbatchhas (cancel_on_sigint,BatchProgressandbatch_viewmoved from private names to shared ones incli/batch.py; the service wiring isbatch_serviceincli/_runs.py). It prints whatbatchprints, with the reference mode read back from the run's stored parameters, and exits0,4,6or130fromBatchResult.error(). Unknown run3; a completed, cancelled or non-batch run2.runs cancel RwrapsBatchService.cancel:{"run": ...}in--json, one line for a person; a completed run is refused (2), a cancelled one is a no-op, an unknown one is3.- The cancel flag file is not built. Item 6 above has
runs cancelon arunningrun also write<state_dir>/runs/<R>.cancel, polled by the live process. That needs the service to poll a file inside its run loop and a second writer of state beside the database; with one user, one terminal per batch and Ctrl-C already pausing a run cleanly, there is no caller for it.runs canceltherefore marks the stored rows only (as P7.2 decided) and does not stop a live process. runs delete R [--assets]isRunRepository.delete_run, with no service on top: it is two queries and a delete. It refuses (UsageError, exit2, naming the dependent run ids) while another run hasparent_run_id = R(a refinement, or a batch whose hero it is) or areference_asset_idamong R's raw and final assets. Otherwise it deletes the run and, through the ORM cascade, its iterations. With--assetsit then deletes the asset rows among R's raw and final images and its reference image that no remaining iteration or run refers to, commits, and only then unlinks their files (a file never goes before the rows that name it). Assets are content-addressed, so an image two runs made is one row and one file and survives the first delete.--jsonprints{"deleted": {"run", "iterations", "assets": [paths removed]}}.- Not touched by
delete: the run's log directory under<state_dir>/logs/runs/<R>/, and the shared provider profile, template and video rows. They are small, other runs may share them, and nothing needs them removed yet. - Hint after a partial or interrupted batch still says to run the same command again (the existing tests pin it), which also works; the user guide names
runs resume <run>as the way that finishes the run itself.
Test plan¶
- Unit:
plan()matrix (completed/skip, running-stale/retry, failed-under-max/retry, failed-at-max/skip-with-reason, new/create);--onlyparsing (3,7-9); reference resolution; roll-up + exit codes; key formula golden values for fixed inputs. - Unit (async):
BatchService.runwith FakeProvider anddelay_ms— semaphore bound, SIGINT viacancel.set(), stale detection with a frozen clock, resume continuation. - Contract: none new.
- Integration (
-m integration): one 2-item batch with Antigravity after S1–S4, S7 close. - Golden: none.
Open spikes¶
- S7 rate limits / quota — may require a fixed delay between Antigravity calls (
[providers.antigravity] cooldown_s, added only if S7 shows throttling). - S8 cost/usage — decides what the summary's cost column shows (Phase 8 cost report reads
iteration.cost_json).