Media Generation Tracking — Technical Design
Status: Draft, for review
Owner: Matti Georgi
Related: packages/execution/src/cost-ledger.ts, packages/runtime/src/cost-reconciler.ts, tool-class-retirement-design.md, HARNESS_FIRST.md
1. Summary
A media generation request is money out and minutes of wall clock, and today the record of it is written after it succeeds, by a listener that never sees a failure, a cancellation, or a process restart. The row it writes names no asset, the asset names no row, and the reconciliation that turns an estimate into the billed amount is a detached promise that dies with the process.
One primitive fixes this: a generation is a row opened before the provider is called and closed with a terminal status, a cost, and the assets it produced. A follower owns that row from submit to close. It is not a new runtime: it is the ledger listener, rewritten to track a lifecycle instead of recording a success, plus a durable reconcile queue and a startup sweep. The follower runs in the process that made the call and outlives the caller (a cancelled chat turn, a finished tool call), so the outcome is recorded even when nobody is waiting for it.
Agents get the record as a capability module, generations: list, get,
await, cancel, reconcile. Generation capabilities gain background: true,
which returns the generation id at once and leaves the follower to finish the
job. The CLI gets the same surface as nodetool generations.
2. What is wrong today
Read with packages/execution/src/cost-ledger.ts and
packages/runtime/src/context.ts open.
- F1. Only success is recorded.
recordFromMessagewrites a row onprediction+status: "completed". Afailedprediction, a cancelled one, or one interrupted by a restart writes nothing. kie bills a task at submit and Meshy bills a preview pass before refine, so a failure is not free. - F2. Cost and asset are not linked. A
nodetool_predictionsrow has no asset id and annodetool_assetsrow has no prediction id. “What did this clip cost” and “which asset came out of this charge” are both unanswerable. - F3. The message id is not the row id.
runProviderPredictionmints arandomUUID()for thepredictionmessage;Prediction.createmints another for the row. A client that watched the message cannot find the row. - F4. Reconciliation is fire-and-forget.
recordNodeProviderCostcallsvoid reconcileProviderCost(...). No retry, no record of whether it ran, and it dies with the process. kie never reconciles at all:reportKieProviderCost(packages/kie-nodes/src/kie-base.ts) omitsprovider_request_idthough the task id is in hand. - F5. Five caller surfaces, three bypass the seam. Workflow nodes and agent
capabilities go through
runProviderPrediction. The chat turn’s direct generation (packages/websocket/src/session/chat-turn.ts), thegenerate_mediaRPC (session/inference.ts) andnodetool generate(packages/cli/src/commands/generate.ts) callprovider.textToImage(...)and friends directly, each with its own asset save and its ownPrediction.create. BYOK direct generation writes no row by design. - F6. 3D generation is off the books.
TextTo3DNodeandImageTo3DNode(packages/video-nodes/src/nodes/model3d/generation.ts) callprovider.textTo3Ddirectly.text_to_3dandimage_to_3dare absent fromProviderCapabilityand fromUNIT_BILLED_CAPABILITIES. Meshy and Rodin spend reads as free. - F7. A
completedprediction message carries the raw output bytes asdata. Every listener, the chat socket forwarder included, receives a full video buffer. Nothing inweb/srcreadsdata; the chat and workflow reducers readstatus,node_idandlogs. - F8. An agent cannot see a generation.
get_cost_summaryaggregates; no capability lists what is running, what a call cost, or where its asset is.
3. Design goals
- One row per generation, opened before spend. The row exists from the moment the provider is asked, whatever happens after.
- Every terminal state is a row state. completed, failed, cancelled, interrupted. Silence is never “free”.
- The asset is the outcome. A completed generation names its assets, and each asset carries its generation id.
- One seam. Every surface that asks a provider for media goes through
ProcessingContext. An audit fails the build on a new bypass. - Reconciliation is durable. A charge that can be reconciled is reconciled, retried with backoff, and the row says whether it was.
- The follower is the capability. Rule 4 of HARNESS_FIRST: the CLI and agent-tool forms are the feature, the dashboard renders them.
4. Architecture
caller (capability · node · chat turn · RPC · CLI)
│ context.runGeneration(req) ← the one seam (§5)
▼
ProcessingContext (packages/runtime/src/context.ts)
id = randomUUID()
emit prediction{status:"running", id, origin, params}
dispatchCapability(provider, req) inside a receipt scope (AsyncLocalStorage)
provider code: recordGenerationReceipt({provider_request_id, cost?})
persist output → createAsset(…, metadata.generation_id = id)
emit prediction{status:"completed", id, asset_ids, receipt, duration}
or {status:"failed", id, error, receipt}
or {status:"cancelled", id, receipt}
return {id, output, assets}
│ addMessageListener
▼
GenerationTracker (packages/execution/src/generation-tracker.ts) ← the follower
running → Prediction.create({id, status:"running", …origin})
completed → price (receipt cost wins, else catalog) · update row · enqueue reconcile
failed → update row · enqueue reconcile when a request id exists
cancelled → update row · enqueue reconcile when a request id exists
ReconcileQueue: rows with provider_request_id, reconciled_at null, attempts < 5
StartupSweep: rows still "running" from a dead process → "interrupted"
│
▼
nodetool_predictions (packages/models) ← one table, lifecycle in place
▲ ▲
│ generations capabilities │ nodetool generations · nodetool costs · Costs dashboard
The dependency arrows are unchanged: runtime knows nothing about models,
execution imports both, hosts attach the tracker where they attach the
ledger today (packages/execution/src/session.ts,
service/workflow-run.ts, packages/websocket/src/session/chat-turn.ts).
D1. Reuse nodetool_predictions; do not add a generations table
The table already carries status (default pending), error,
provider_request_id, parameters, metadata, started_at,
completed_at, duration, and the unit-billing columns. nodetool costs,
get_cost_summary and the Costs dashboard read it. A second table would
split “what ran” from “what it cost” and every consumer would join them. The
lifecycle goes into the row that already exists; the write path changes from
insert-on-success to insert-on-start plus update-on-close.
D2. The follower is a listener plus a durable queue, not a worker process
The provider call is already an in-process await inside the SDK or the
provider’s own poll loop (kie-provider.ts pollUntilDone,
gemini-provider.ts operation polling, meshy-provider.ts pollTaskStatus).
The follower does not take that over. It watches the messages the seam emits,
which is what the ledger does today, and adds the two things a listener
cannot give: a queue that survives a restart (reconciliation, stored in the
row) and a sweep that closes rows the restart orphaned. Resumable polling of a
provider job across restarts needs a submit/poll split in every provider and is
out of scope (§12).
D3. Receipts flow through AsyncLocalStorage, not through return types
Twenty-five provider methods return bytes. Threading a request id through
each signature is a large diff for one field. packages/runtime/src/invocation-account.ts
already scopes {costUsd, createdAssets} per invocation with
AsyncLocalStorage; the receipt scope is the same shape. A provider calls
recordGenerationReceipt({provider_request_id, cost?}) from inside the method
and the seam reads it after dispatch. FAL (client.subscribe returns
requestId) and kie (taskId, creditsConsumed) fill it in first.
D4. The seam persists the asset
Today persistOutput in packages/agents/src/tools/asset-persist.ts saves the
asset after the seam returns, so the seam cannot name it. runGeneration takes
a persist option and saves through createAsset before it emits
completed, stamping metadata.generation_id, workflow_id, node_id and
job_id on the asset. The caller gets assets back and stops saving on its
own. A host with no createAsset interface (a hermetic eval, the CLI without a
database) gets the bytes and writes a workspace file as before; the row then
records asset_ids: [] and metadata.persisted: "workspace".
5. The seam
In packages/runtime/src/context.ts.
export type GenerationSurface =
| "workflow" | "capability" | "chat" | "rpc" | "cli";
/** Who asked. Every field the row and the asset carry back to the caller. */
export interface GenerationOrigin {
surface: GenerationSurface;
thread_id?: string | null;
tool_call_id?: string | null;
job_id?: string | null;
node_id?: string | null;
}
export interface GenerationRequest extends ProviderPredictionRequest {
origin?: Partial<GenerationOrigin>; // defaults from the context (§5.1)
persist?: {
name?: string; // asset name; default `<capability>-<ts>.<ext>`
mime?: string; // default sniffed from bytes, then by capability
parentId?: string; // asset folder
};
signal?: AbortSignal; // cancel_generation reaches the provider through it
}
export interface GenerationResult<T = ProviderPredictionResult> {
id: string; // the row id and the message id (F3)
output: T;
assets: AssetRef[]; // empty when nothing was persisted
receipt: GenerationReceipt | null;
duration_ms: number;
}
export interface GenerationReceipt {
provider_request_id?: string | null;
/** The provider's own charge, when it states one. Wins over the catalog. */
cost?: Pick<ProviderCost, "amount" | "currency" | "billing_unit" | "quantity" | "unit_price"> | null;
}
runGeneration(req: GenerationRequest): Promise<GenerationResult>;
runProviderPrediction(req) stays and becomes
(await this.runGeneration(req)).output, so the node call sites keep
compiling. textToSpeechEncoded, textToMusic and streamProviderPrediction
get the same lifecycle (they already emit running/completed/failed; they
gain the origin, the receipt and the cancelled state).
5.1 Origin defaults
The context knows userId, jobId, workflowId and threadId. A request
that names no origin gets surface: "workflow" with the context’s job and
workflow ids inside a run, and surface: "chat" with its thread id inside a
chat turn. A capability passes surface: "capability" and its tool_call_id
explicitly, because that is the one id the context does not hold.
5.2 Message changes
predictionSchema in packages/protocol/src/messages.ts:
statusnarrows to"running" | "completed" | "failed" | "cancelled"on the wire;"interrupted"exists only in the row, written by the sweep.- add
origin: GenerationOrigin,asset_ids: string[],receipt: GenerationReceipt | null. datastops carrying bytes (F7). It stays in the schema, nullable, for the segmentation result (ImageSegmentationMask[]metadata without pixels) and the ASR transcript, which are results and not media.
params is redacted before it is emitted or stored: byte fields, data: URLs
and base64 blobs are replaced with {bytes: n}. The redaction is the one the
supervisor uses for Escalation.inputs.
5.3 Capabilities added to the seam
text_to_3d and image_to_3d join ProviderCapability,
dispatchCapability and UNIT_BILLED_CAPABILITIES (F6). The two 3D nodes call
runGeneration instead of context.getProvider(...).textTo3D(...).
5.4 Cancellation
runGeneration registers {id, abort} in a process-level
GenerationRegistry for the life of the call. The registry is the shape of
packages/agents/src/background-subtasks.ts (a record map, a version counter,
a waiter list), lifted to the process because a generation outlives the turn
that started it. cancel_generation aborts it;
the seam emits cancelled with whatever receipt the provider recorded before
the abort. Providers that already take signal (image and video params)
stop polling; the rest finish their current HTTP call and the result is
discarded. Cancelling does not refund: a provider that bills at submit still
bills, which is why a cancelled row keeps its request id and is reconciled.
6. The follower
packages/execution/src/generation-tracker.ts, replacing the prediction
branch of recordFromMessage. The node_update + provider_cost branch stays
for the node packages that call a provider SDK directly (§8, S7).
6.1 Row lifecycle
| Message | Row write |
|---|---|
running |
Prediction.create({id, user_id, status: "running", provider, model, capability, surface, thread_id, tool_call_id, job_id, node_id, workflow_id, project_id, document_id, parameters, started_at}) |
completed |
update({status: "completed", cost, billing_unit, quantity, unit_price, currency, provider_request_id, asset_ids, duration, completed_at, metadata.price_source}), then enqueue reconcile when provider_request_id is set |
failed |
update({status: "failed", error, provider_request_id, completed_at}); enqueue reconcile when a request id exists, because a failed call may be billed |
cancelled |
as failed, with status: "cancelled" |
Pricing order on completed: the receipt’s cost when the provider stated one
(metadata.price_source: "provider"), else the catalog through
priceGeneration ("model-catalog"), else cost: null with
unpriced_reason, as today. A null stays null: an unpriced row is countable,
a zero is a lie.
A create that fails (no database on this host) is logged at warn and the
tracker remembers the id, so the later update for that id is skipped instead
of logged again. Bookkeeping stays best-effort for a hermetic run, and loud.
6.2 Reconcile queue
State lives in the row: reconciled_at, reconcile_attempts,
metadata.reconcile_error. The queue is a query, not memory:
provider_request_id IS NOT NULL AND reconciled_at IS NULL
AND reconcile_attempts < 5 AND status IN ('completed','failed','cancelled')
A worker drains it on a timer (every 5 minutes; the first attempt for a fresh
row after 60 s, because billing events lag the request) with attempts spaced
1, 5, 30, 120, 720 minutes. A provider with no reconciler marks the row
reconciled_at = now, metadata.reconcile: "unavailable" on the first pass so
it leaves the queue. The FAL reconciler stays as it is
(packages/fal-nodes/src/fal-billing.ts); kie gains one over
/api/v1/jobs/recordInfo, whose status payload already carries
creditsConsumed. The worker is attached once per process by the server
(packages/websocket) and by the CLI when it opens a database; an eval attaches
none.
6.3 Startup sweep
On attach, rows with status = "running" and started_at older than the
process start are set to interrupted with
metadata.interrupted_reason: "process restart", and enqueued for reconcile
when they carry a request id. A row a live process is still driving is never
older than its own start, so the sweep cannot close a real run.
6.4 Run totals
packages/websocket/src/session/job-execution.ts accumulates a run’s cost
for the job row from the same messages (_handlePredictionCost,
_handleNodeProviderCost, runMeasuredCost), re-pricing each prediction
itself. It keeps doing that, from the new receipt field first and the
catalog second, so the job total and the row agree by construction. It does
not write rows; that stays the tracker’s.
6.5 Double counting
One generation, one row. Two guards:
- A node that goes through the seam and calls
setProviderCost(the FAL and kie factories, once they move to the seam in S7) would write a row from thepredictionmessage and a second one fromnode_update.provider_cost. The tracker records the invocation’s generation ids; anode_updatewhose invocation already produced a row with a receipt cost writes nothing, and anode_updatewhose row exists without a receipt updates that row instead of inserting. The kernel already scopes an invocation (invocation-account.ts), so the mapping is by invocation, not by guess. runDirectMediaGeneration(inference.ts) writes its ownPrediction.createafterreserveSpend. That write goes; the seam’s row is the row, and the nodetool-provider budget path keepsadmitSpend/reserveSpend/releaseSpendaround therunGenerationcall.
7. Schema
packages/models/src/schema/predictions.ts and schema-pg/predictions.ts,
one migration in migrations/versions.ts:
| Column | Type | Why |
|---|---|---|
capability |
text | the ProviderCapability; today only in metadata |
surface |
text | GenerationSurface |
thread_id |
text, index (user_id, thread_id) |
the chat turn that asked |
tool_call_id |
text | the capability call that asked |
job_id |
text, index | the run that asked |
asset_ids |
json text | the outcome (F2) |
reconciled_at |
text | queue state (§6.2) |
reconcile_attempts |
integer, default 0 | queue state |
plus index (user_id, status) for list_generations. Existing rows keep
status: "completed" and nulls elsewhere; nothing is backfilled.
Assets: no column. metadata.generation_id on the asset row, written by the
seam, is enough for get_generation and for an asset panel to show “cost
$0.41, 6 s of Veo” on the clip. nodetool_assets already carries
workflow_id, node_id and job_id.
Aggregates (Prediction.aggregateDashboard, aggregateByUser,
aggregateByProvider, aggregateByModel, listSpendByProject) add
status != 'running' so an open row is neither summed nor counted as
unpriced, and gain running_count and interrupted_count in
AggregateResult. The filter excludes rather than allows because the token
writers (chat-turn.ts _logProviderCall, cli/src/supervisor.ts,
app-build/build.ts) all write completed today and none of them is touched
here. Spend sums include a failed row that carries a cost, because that is
money out.
8. Surfaces that must go through the seam
Enumerated, not sampled. Each one either moves in this design or is named as staying out with the reason.
| Surface | Today | Change | |
|---|---|---|---|
| S1 | Agent capabilities: generate_image, edit_image, segment_image, generate_video, animate_image, generate_speech, generate_music (packages/agents/src/capabilities/media.ts) |
runProviderPrediction then persistOutput |
runGeneration with persist and origin: {surface: "capability", tool_call_id}; drop the capability-side save; return generation_id next to asset_id |
| S2 | render_storyboard_stills, render_storyboard_clips, revise_storyboard_clip (capabilities/storyboards.ts), voice_script_lines (capabilities/scripts.ts, which calls generateSpeech.impl) |
same as S1 | same as S1; the shot and the take record the generation id next to the asset id |
| S3 | Workflow nodes through runProviderPrediction (image-nodes, video-nodes, audio-nodes, video-nodes/script.ts, llm-nodes/shots.ts) |
seam, no persist, autoSaveAsset on the websocket host (packages/websocket/src/session/asset-autosave.ts, driven per generation_complete, deduped on metadata.generation_index) |
unchanged call; origin defaults to the run. The node returns an inline ref, so the seam cannot name the asset. Linking goes the other way: the tracker keeps, per (job_id, node_id), the generation ids it opened and has not linked; autosave asks for them when it saves that node’s generation_complete, stamps metadata.generation_ids on the asset, and the tracker writes asset_ids on those rows. A node that made several generations in one invocation links all of them to the asset rather than guessing which one |
| S4 | 3D nodes (video-nodes/src/nodes/model3d/generation.ts) |
direct provider.textTo3D (F6) |
runGeneration with the two new capabilities |
| S5 | Chat turn direct generation (chat-turn.ts handleMediaGenerationMessage) and generate_media RPC (inference.ts runDirectMediaGeneration) |
direct provider methods, own asset save, own Prediction.create (RPC) |
ctx.runGeneration with persist and origin: {surface: "chat" \| "rpc", thread_id}; delete both local storeAsset helpers and the RPC’s row write; BYOK gets a row, unpriced when the catalog has no price |
| S6 | nodetool generate (packages/cli/src/commands/generate.ts) |
direct provider methods, node:fs write, hand-rolled recordGenerationSpend |
build a ProcessingContext (the CLI already does for node run), runGeneration with origin: {surface: "cli"}; -o still writes the file; the row and the asset come from the seam when a database is open |
| S7 | Provider node packages that drive an SDK themselves: fal-nodes, kie-nodes, atlascloud-nodes, together-nodes, topaz-nodes, minimax-nodes, replicate-nodes, reve-nodes, huggingface-nodes, elevenlabs-nodes, integration-nodes/kie-dynamic.ts |
setProviderCost on FAL and kie only, autoSaveAsset for the media, no row for the rest |
Stays on the node_update path in this design. Two fixes ship with it: kie passes taskId as provider_request_id (F4) and fal-dynamic.ts calls reportFalCost like fal-factory.ts does. Moving these packages behind the seam means giving dispatchCapability an endpoint-shaped request, which is its own design. The audit (§9) lists them as an allowed set so the debt is visible, not forgotten |
9. Audit
packages/execution/tests/generation-seam-audit.test.ts, modelled on
packages/runtime/tests/url-egress-audit.test.ts. It scans packages/*/src
for a call to any media method of BaseProvider (textToImage,
imageToVideo, textTo3D, … the list is read from base-provider.ts so a
new method is covered the day it lands) outside packages/runtime/src/ and
outside the S7 allow-list, and fails on the first unclassified call. It also
asserts it found the S7 calls, so it cannot pass by matching nothing. It is
inverted once in the PR that adds it: a bare provider.textToImage( in a
scratch file under packages/agents/src/ must turn it red.
10. Capabilities
New module packages/agents/src/capabilities/generations.ts +
generations.specs.ts. Wiring follows the checklist every module pays:
the spec import, the loader in MODULES, the name in
DECLARED_CAPABILITY_MODULES and the entry in MODULE_SPECS of
capabilities/registry.ts; the category snapshot in
packages/agents/tests/capabilities-registry.test.ts; the generations
namespace in the CodeAct API map (codeact/nodetool-api.ts), so guest code
gets nodetool.generations.wait(id) next to nodetool.jobs.wait(id); the
tRPC classification in packages/websocket/src/trpc/sandbox-coverage.ts
for any procedure the dashboard gains to list generations (classified
capability, since the module covers it); the module table in
docs/AGENTS.md; and a capability-table.ts entry per capability naming
packages/agents/tests/capabilities-generations.test.ts and
packages/execution/tests/generation-tracker.test.ts.
| Name | Category | Input | Returns |
|---|---|---|---|
list_generations |
read | status?, provider?, capability?, thread_id?, job_id?, since? (ISO), limit (default 50, max 500) |
{generations: GenerationSummary[], next}: id, status, provider, model, capability, cost, currency, asset_ids, started_at, duration, error |
get_generation |
read | generation_id |
the full row: parameters (redacted), price breakdown, receipt, reconcile state, assets as asset:// refs, origin |
await_generation |
read | generation_id, timeout_seconds (default 300, max 1800) |
the row once terminal, or {status: "running", waited_seconds} on timeout. In-process it subscribes to the registry; across processes it polls the row every 5 s |
cancel_generation |
write | generation_id |
{generation_id, status: "cancelled"} or {cancelled: false, error} when the id is not running or not the caller’s. The category matches cancel_job, and so does the row write: one UPDATE with id, user_id and status = 'running' in the WHERE (Job.markCancelledIfActive), then the abort |
reconcile_generation |
external | generation_id |
{before: cost, after: cost, reconciled: boolean, reason?}; runs the provider’s reconciler now, outside the queue’s schedule |
Every read is scoped: Prediction.find is unscoped today and stays that way
for the ledger; the module uses a new Prediction.findForUser(userId, id) and
Prediction.listGenerations(userId, filter), both with user_id in the
WHERE.
10.1 background: true on the generation capabilities
generate_image, edit_image, generate_video, animate_image,
generate_speech, generate_music, render_storyboard_stills and
render_storyboard_clips accept background: true. The capability calls
runGeneration without awaiting the result, reserves the estimated cost
against the run’s RunBudget (the same reservation a synchronous call makes),
and returns {generation_id, status: "running", estimated_cost} in the same
turn. The follower closes the row; await_generation collects it. A
background generation an agent never awaits is still finished, recorded, and
its asset saved, which is the whole point.
Bounds: at most 16 background generations open per run (the number the native flow allows for streams), and none once the budget is exhausted. A run that ends with background generations open leaves them running: they are the follower’s, not the turn’s.
10.2 What is returned to the model
Every generation capability adds generation_id to its result next to
asset_id, so a model that wants the cost of the image it just made calls
get_generation with an id it already holds. persistOutput’s SavedOutput
shape is unchanged apart from that field.
11. Harness
- CLI:
nodetool generations list [--status] [--provider] [--since] [--json],get <id>,await <id> [--timeout],cancel <id>,reconcile <id>, plusnodetool generations sweep(run the startup sweep and one queue drain by hand).nodetool costs listgainsstatusandassetscolumns. Each subcommand takes--json; exit code is the verdict. - Selfcheck:
packages/execution/tests/generation-tracker.test.tswith the fake provider: running→completed writes one row with the asset id and the message id; failed writes a failed row; an aborted call writes cancelled; a row older than process start is swept to interrupted; a receipt cost wins over the catalog; a reconciler that throws increments attempts and leaves the row queued; five failures leave the queue.packages/agents/tests/capabilities-generations.test.tsdrives the five capabilities andbackground: truethrough the headless run, and asserts a foreign user’s row is invisible. - Registry: surface
generation-trackinginpackages/cli/src/harness/registry.tswith pathspackages/execution/src/generation-tracker.ts,packages/agents/src/capabilities/generations*.ts,packages/runtime/src/generation-receipt.ts, covered bycapability-suites; the audit test is named as its selfcheck. - Eval: one
creative-pipelinecase where the objective is “render these four shots and tell me what each cost”, scored onrender_storyboard_stillsfollowed byget_generationorlist_generations.
12. Out of scope
- Resumable provider polling. After a restart, a kie task or a Veo
operation is still running at the provider. Picking it up again needs a
submit/pollsplit per provider (kie,gemini,openai,meshy,rodin,minimax,together,xai,atlascloud,evolink,topaz; FAL and Replicate hide it inside their SDKs). The row records the request id so the cost is recovered; the media is not. This is the next design once the row exists. - Moving S7 behind the seam (§8).
- Budget refusal before the call.
admitSpendexists for the nodetool provider only; extending the pre-run gate to BYOK is a pricing question, not a bookkeeping one. - UI. The Costs dashboard and an asset panel badge render the row; neither is designed here.
13. Rollout
| Phase | Ships | Gate |
|---|---|---|
| P1 | Schema, runGeneration + receipts + persist, tracker with queue and sweep, S1/S2 capabilities on the seam, generations module, CLI, audit (with S4/S5/S6 in the allow-list, marked TODO(P2)) |
tracker and capability suites; audit inverted; capabilities:check; harness gate |
| P2 | S4, S5, S6 on the seam; S7 fixes (kie request id, fal-dynamic cost); allow-list shrinks to S7 | audit shrinks and stays green; generate_media route tests; CLI generate writes a row through the seam |
| P3 | background: true, await_generation, cancel_generation wired to the registry; aggregate changes and dashboard columns |
capability suite for background; costs route tests |
P1 is the bookkeeping the title asks for. P2 closes the bypasses. P3 is the ergonomics.
14. Risks
- R1. A row for every call doubles the write rate on the predictions table. Two writes per generation instead of one, on a table indexed five ways. A generation is seconds to minutes of provider time; two SQLite writes are not the bottleneck. Watched, not mitigated.
- R2. The sweep marks a slow generation interrupted after a restart while the provider finishes it. Correct: the process that would have collected the bytes is gone. The row says so, keeps the request id, and reconciles the cost. The media is lost until §12’s resumable polling.
- R3. A provider reports a request id but no reconciler exists. The row
leaves the queue on the first pass as
unavailable; the estimate stands. No retry storm. - R4. Receipt scope leaks across concurrent generations. AsyncLocalStorage
isolates per async context; a provider that schedules work outside the
awaited chain (a detached timer) would record into nothing. The receipt call
logs at
debugwhen it finds no scope, and the tracker test runs two generations concurrently and asserts each row gets its own id. - R5. Dropping bytes from
prediction.databreaks a consumer. Enumerated:web/src/stores/workflowUpdates.ts,web/src/core/chat/chatProtocol.ts,web/src/hooks/timeline/useGenerateClip.ts,web/src/hooks/sketch/useGenerateLayer.ts,packages/websocket/src/chat-prediction-forwarder.ts. None readsdata. Mobile is checked in the PR the same way. - R6.
background: truespends money a turn never reports. The reservation againstRunBudgethappens at submit, so the cap holds; the turn’s cost summary counts reserved spend as spent and saysn generations still running.