fix: workflows are charged for cached model answers and stop on a budget they never spent - #134
Conversation
A cache or run-state hit returns the stored result verbatim, including the usage of the call that produced it. trackStepCost recorded that usage again, so every replay was charged as if it had reached a provider. A workflow that asks the same question in several steps therefore reports a multiple of what it spent, and cost_limit with action: stop aborts a run that stayed well inside its budget. Skip items whose source is "cache" or "run_state". Those are the only two paths that re-emit a stored result, and no tokens are consumed on either, so the reported token totals stay truthful too.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f37fc8e649
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
|
|
||
| function isReplayedResult(item: Record<string, unknown>) { | ||
| return item.source === "cache" || item.source === "run_state"; |
There was a problem hiding this comment.
Restrict replay detection to llm.invoke results
Because trackStepCost is applied to every workflow result, this predicate now suppresses any step output that happens to include source: "cache" or source: "run_state", even when it is a live result with real usage. This is reachable for supported direct LLM adapters too: a direct adapter’s source defaults to the provider name in src/commands/stdlib/llm_invoke.ts:549, so a live llm.invoke --provider cache call, or any shell command emitting that source field, bypasses _meta.cost and cost_limit entirely. Please gate this on the normalized llm.invoke/llm_task.invoke item shape or another unambiguous replay marker instead of the generic source string alone.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, and it was real. source is not a replay marker: for a direct adapter it defaults to the provider name (llm_invoke.ts:549), and resolveProvider accepts any key present in ctx.llmAdapters (:512), so an embedder registering an adapter as cache or run_state would have had its live calls silently dropped from _meta.cost and cost_limit — the mirror of the bug this PR fixes. A shell step emitting that field had the same effect.
Pushed 1d77392, which stops inferring the replay and marks it at the two sites that actually re-emit a stored item:
reused.items.map((item) => ({ ...item, source: "run_state", cached: true, replayed: true }))
cache.items.map((item) => ({ ...item, source: "cache", cached: true, replayed: true }))// src/workflows/file.ts
if (record.replayed === true) continue;I marked the replay rather than gating on the llm.invoke / llm_task.invoke item shape, because the shape check does not close the case you named: a live call through an adapter registered as cache still carries kind: "llm.invoke". cached cannot carry it either — it is true for any source outside the known remote list (:908), so it is already true on that same live call.
The flag is set when the item is replayed, not when it is stored, so cache entries already on disk are covered without invalidation.
A new test covers exactly your case, and it fails against the version you flagged. Two live steps whose source is cache and run_state:
$ node --test --test-name-pattern "adapter named like a replay source" dist/test/cost_tracker.test.js
not ok 1 - workflow cost tracking bills a live call from an adapter named like a replay source
Expected values to be strictly equal:
+ actual - expected
+ undefined
With the marker, both are billed:
ok 23 - workflow cost tracking bills a live call from an adapter named like a replay source
Suite on the same machine: 313 tests / 206 pass / 107 fail, against 309/202/107 on main @ 0ac962e — four added tests, four added passes, the same pre-existing failures. tsc --noEmit clean, oxfmt --check all 118 files correct.
The real-adapter run is unchanged: the same three-step workflow with cost_limit: {max_usd: 0.0002, action: stop} still completes on one Ollama request, and the replayed item now carries "replayed": true in its output.
…them The cost guard keyed on source being "cache" or "run_state", but source is not a replay marker: for a direct adapter it defaults to the provider name (llm_invoke.ts:549), so an embedder registering an adapter under either name produces live results that the guard would silently drop from _meta.cost and cost_limit. Any step emitting that source field had the same effect. Set an explicit replayed flag at the two sites that re-emit a stored item, and key the guard on it. The flag is added when the item is replayed, not when it is stored, so entries already on disk are covered.
|
Codex review: needs changes before merge. Reviewed August 8, 2026, 5:28 PM ET / 21:28 UTC. ClawSweeper reviewWhat this changesThe PR adds provenance-aware LLM cost accounting so cached and run-state workflow replays are charged once per real provider call across pipelines, retries, state, and resumes. Regression provenancePossible regression — suspected (reviewed change). No predecessor PR is attributed. Merge readiness⛔ Blocked by patch quality or review findings - 5 items remain Keep open: the current head still lets public JSON fields suppress later workflow usage, so a workflow can bypass its own budget accounting. Priority: P1 Review scores
Verification
How this fits togetherLobster workflows run pipeline steps such as LLM invocations and aggregate their reported usage into flowchart LR
A[Workflow steps] --> B[LLM invocation]
B --> C[Cache and run state]
B --> D[Per-run charge ledger]
C --> E[Pipeline transforms]
E --> F[Workflow cost accounting]
D --> F
F --> G[Cost limit and run result]
Before merge
Findings
Agent review detailsSecurityNeeds attention: The new accounting boundary lets untrusted JSON suppress recorded workflow spend. Review metrics
Root-cause clusterRelationship: Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge-risk optionsMaintainer options:
Copy recommended automerge instructionTechnical reviewBest possible solution: Restrict cost suppression to non-forgeable in-process provenance or a narrowly trusted resume boundary, and treat ordinary JSON copies from shell or external-process stages as billable. Do we have a high-confidence way to reproduce the issue? Yes — source-reproducible: first settle a legitimate LLM charge, then emit matching public Is this the best way to solve the issue? No — the current path treats public JSON as proof that a charge was already accounted for; suppression must remain limited to private provenance or a narrowly trusted boundary. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 0ac962e90b78. LabelsLabel changes:
Label justifications:
EvidenceSecurity concerns:
Acceptance criteria:
What I checked:
Likely related people:
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (60 earlier review cycles; latest 8 shown)
|
trackStepCost inspects every JSON result a workflow step produces, not just LLM results, so keying the exemption on a bare replayed field let any command that reports its own replay state beside a real usage object drop out of _meta.cost and slip past cost_limit. Under-reporting spend is the mirror of the bug this branch fixes. Gate the exemption on the normalized item llm.invoke and llm_task.invoke actually re-emit: the replay marker plus cached, a known item kind, and the string cacheKey, status, createdAt, and source fields the command always writes. A shell step or an unrelated tool that happens to carry replayed no longer qualifies. Checking the shape alone would not be enough either, since a live call through an adapter registered as "cache" carries the same kind.
|
The finding is right, and it is the mirror of the bug this PR fixes: Pushed export function isReplayedLlmItem(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
if (record.replayed !== true || record.cached !== true) return false;
if (typeof record.kind !== "string" || !REPLAYABLE_LLM_ITEM_KINDS.has(record.kind)) return false;
return (
typeof record.cacheKey === "string" &&
typeof record.status === "string" &&
typeof record.createdAt === "string" &&
typeof record.source === "string"
);
}Both halves are load-bearing. The shape alone does not close the earlier finding on this PR — a live call through an adapter registered as Two new tests, and both fail against the head you flagged ( With The first bills a step that emits Genuine replays are unaffected — measured against a real model. Same three-step workflow through a local Ollama bridge, cold cache,
this branch: One request reached the model in both runs; only the replayed steps differ. The replayed item still qualifies, because it carries the whole contract: {"kind":"llm.invoke","source":"cache","cached":true,"replayed":true,"cacheKey":"bf42ef7ffb…","status":"completed","createdAt":"2026-08-03T18:38:30.631Z","usage":{"inputTokens":23,"outputTokens":3,"totalTokens":26}}Suite on the same machine: 315 tests / 208 pass / 107 fail, against 313 / 206 / 107 at @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
The replay marker was a set of JSON fields, and workflow cost accounting reads the JSON of every step — including shell steps, whose stdout is parsed straight into the same shape. A step that printed a replay-shaped object beside a real `usage` object dropped out of `_meta.cost`, which under-reports spend and lets a configured `cost_limit` be bypassed. Mark replays with a symbol key instead. `JSON.parse` cannot produce one, so only items this process built in `llm.invoke` are exempt; the public `replayed` field stays for consumers but no longer grants the exemption. Tests now drive the exemption through the real path (a pipeline step running `llm.invoke` against a stub provider, for both the cache and run-state replays) and pin the forged case: a shell step printing every accepted replay field plus real usage is still billed and still trips `cost_limit`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6UVfFPP39RkoYx5jZ3KKM
|
The P2 finding is fixed in What was wrong
What changed
The symbol is The full-shape caseTwo tests print every field a replayed item carries — Test 26 is the one that matters: the forged step runs after a live call inside a The exemption still works, established from the provider's sideTests 20 to 22 drive the real
Pipeline steps carry the item as an in-process object rather than re-parsed text, which is what lets provenance survive the trip to Mutation controlWith both production files restored to the previous revision of this branch and the new tests kept, exactly tests 25 and 26 fail and nothing else moves. Tests 20 to 22 stay green there, because a genuine replayed item satisfies the old field shape too. That difference is the entire delta of this revision, and it shows the added coverage is load-bearing rather than self-confirming. One note on running these tests on WindowsTests 20 to 22 need a cache or state directory, and on Windows they fail before reaching any assertion with |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8ffb0522a0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // it again charges a run for tokens no provider was asked to spend. The exemption keys | ||
| // off provenance the LLM commands attach in-process, not off fields in the JSON: a step | ||
| // that prints a replay-shaped object stays billed, so `cost_limit` cannot be evaded. | ||
| if (isReplayedLlmItem(record)) continue; |
There was a problem hiding this comment.
Preserve replay provenance through rendered pipelines
In workflow pipeline: steps that end with the built-in json renderer, a cached llm.invoke | json still gets billed again: json renders the items to stdout and returns an empty stream (src/commands/stdlib/json.ts:14-15), so runPipelineStep reparses renderedStdout into fresh objects (src/workflows/file.ts:2961) that cannot carry this in-process symbol. This check therefore misses replayed items for that supported pipeline shape, and repeated cached LLM calls can still inflate _meta.cost or trip cost_limit even though no provider was called.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 35374f4, some revisions ago — this thread never got its answer, so for the record.
runtime.ts now wraps each stage's renderer so the pipeline keeps the objects the renderer consumed and returns them as renderedItems. runPipelineStep attaches those to the step result under a second, non-enumerable symbol, but only when the pipeline itself produced no items, and trackStepCost bills them in preference to the JSON re-parsed from stdout. So llm.invoke | json keeps its replay provenance, while a step that prints the replay shape through exec --json=true node file.mjs | json is still billed — tests 27 to 31 pin both halves.
table needs no equivalent: it writes non-JSON to stdout, so parseJson yields nothing to bill in the first place.
`llm.invoke | json` is a supported step shape, and it defeated the symbol-keyed replay exemption. The renderer prints its items and returns an empty stream, so the step has no pipeline items and its JSON is parsed back out of stdout — objects that no symbol can survive into. Cached and run-state replays were billed again there, inflating `_meta.cost` and stopping workflows below their real provider spend. Keep the originals instead of trying to recover them. Each stage's renderer now records the objects it was handed, the pipeline returns them alongside its items, and a step whose pipeline produced no items carries them on a non-enumerable symbol key. Cost accounting reads those in preference to the re-parsed JSON, so a rendered replay is recognized for what it is. Nothing about the forged case changes: the side channel holds only items this process built, so a step that prints the full replay shape — through a renderer or not — is still billed and still trips `cost_limit`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YJKpXm7WiSbyjQzrFS5Ps
|
The JSON-renderer finding was right, and Why the symbol was not enough. What changed. Rather than trying to recover provenance from the serialized text, the pipeline keeps the objects it already had. Each stage's renderer records what it was handed, The forgery guarantee is unchanged, because the side channel holds only objects this process constructed and is never serialized. Real-adapter evidence at the new head. Same counting bridge to a local Ollama
With Coverage. Five tests added, 27 to 31. Against Suite on this Windows machine: @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
`wait: "any"` returns the first branch to answer and abandons the rest, but an abandoned branch is not a stopped one: it can still be waiting on a provider and it pays when the answer arrives. That charge went straight to the run ledger, so whether the workflow billed it turned on nothing but how long the run happened to live afterwards — the same workflow over the same three calls reported 2000 tokens or 3000, and `cost_limit` inherited the same coin flip. `main` is deterministic here, so this was the ledger's own regression rather than a boundary it merely exposed. A losing branch now opens its charges in a buffer of its own and only the winner releases them into the run: the output of a discarded branch is thrown away and its accounting goes with it, which is what `main` already did. Reads still pass through, because they settle charges opened before the race and are made by the workflow after a branch returns, never from inside one. `wait: "all"` keeps every branch, so it bills as before. Whether losers should instead be drained before the step settles is a separate question about wait-any latency, and is left open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012AVQmKec1UHHXyUhx4BNvS
|
Fixed in I had been reading "define the cancellation/settlement contract" as only the drain-or-not question. Measuring it instead. One
So the discarded branch's spend lands in the workflow's own total only when the run happens to still be alive as the provider answers. The fix. A losing branch opens its charges in a buffer of its own, and only the winner releases them into the run ledger — right where the race is decided, before Reads pass straight through. That is safe rather than lucky: inside a pipeline the only ledger call is Regression test: Suite with #126 applied, same command both sides: What is still open, and I am not deciding it here: whether @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9b111be8b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| llmSpendLedger.claim(provenance.cacheKey, { | ||
| model, | ||
| usage: usage as Record<string, unknown>, | ||
| }); |
There was a problem hiding this comment.
Use the settled charge for live LLM items
When a supported in-process stage keeps the provenance but changes or drops the public model field, such as llm.invoke --model gpt-4o ... | pick usage or | map model=gpt-4o-mini, this call can still settle the original live charge but the returned charge is ignored. The later recordUsage bills the transformed or missing model instead of the provider call, producing zero cost for pick usage or the wrong model price for map, so _meta.cost and cost_limit can under- or over-report real spend; use the claimed charge's model/usage whenever the claim succeeds.
Useful? React with 👍 / 👎.
A step's item reaches cost accounting after its pipeline has run, and a pipeline can drop the model on the way -- `| pick usage` hands on the marked usage record without it -- or rewrite it. The step was then priced from what the item said rather than what the provider was asked for: nothing at all in the first case, another model's rate in the second, with `cost_limit` reading the difference as room left. The charge the call opened already carries the real model and usage, and the deferred replay path is billed from it for the same reason. A charge restored from resume state written before it carried a cost still falls back to the item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JYzZx7UUfvMLvdtkiMaMs1
|
Fixed in The item that reaches
The fix. Regressions added in
Suite One note for the record, not a defence: @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a91734fbd1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const isSameCall = (charge: LlmChargeCost) => | ||
| (charge.model ?? null) === model && sameBillableUsage(charge.usage, usage); |
There was a problem hiding this comment.
Settle cache-keyed copies that drop the model
When a live llm.invoke is passed through a supported JSON stage that preserves cacheKey/usage but drops or rewrites model (for example exec --stdin json --json <filter> returning {cacheKey, usage}), this match rejects the open charge because the copied model no longer equals the recorded one. settleDeferredCosts then records the copied item, and the later unbilled-charge sweep records the original provider charge too, so one live call becomes two token records or adds cost under the rewritten model; cache-keyed copies should settle and bill from the recorded charge rather than trusting the copied model field.
Useful? React with 👍 / 👎.
|
I reproduced this one rather than pushing a fix, because the measurements say the repair costs more than the gap it closes. Numbers first, then why. Same workflow shape throughout: one
Row B is the finding, and it reproduces exactly as described. Rows A, C and D are what it costs to close. D is why this is not a budget bypass. A step that wants to spend without being counted does not need to forge a cache key and match an earlier call's token counts to the digit — it omits C bounds the reach. Suppression needs the key and the exact billable usage of a call this run already settled. One token off and the record is billed. A is what the repair breaks. The "external-JSON regression" and the honest case are the same bytes. A cached replay that crosses a process boundary comes back with its cache key, its model and its usage intact and every in-process symbol gone — indistinguishable, at this layer, from a step printing a lookalike. Removing key-based suppression bills it a second time: $0.015 for one provider call, which is issue #133 reopening through the process edge. In On "require non-forgeable provenance instead": a copy is by definition a value that lost every mark this process attached. The public fields are the only evidence that survives, so there is nothing non-forgeable left to require. A signed token in the JSON does not help either — a forger obtains it the same way they obtain the cache key, by reading the earlier step's output, which is precisely the legitimate copy. So the two properties are exclusive, and the branch already picked deliberately: Happy to take it either way, but it is your call which loss to accept, so I would rather not choose it for you:
If you want (2) I will push it with the transcript, and reopen #133 with the narrowed scope. Measurement harness (stub provider, three worktrees, per-scenario cache dirs) available if you want it as a test rather than a comment. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1d1a78d81
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const matches = settled.some( | ||
| (charge) => | ||
| charge.cacheKey === cacheKey && | ||
| (charge.model ?? null) === model && |
There was a problem hiding this comment.
Restore provenance for projected pre-pause results
When a step before an approval/input gate uses a supported projection such as llm.invoke --model gpt-4o ... | pick cacheKey,usage, the live call is billed correctly from the ledger before pausing, and llmBilled is restored with model gpt-4o; however the saved step result has no model, so this equality prevents restoreLlmProvenance from reattaching the replay mark. If a later resumed step re-emits $live.json, that unmarked copy is billed again on top of the restored cost. For trusted resume state, the restored charge should be matched by cache key and billable usage without requiring the projected public model field to still equal the original.
Useful? React with 👍 / 👎.
# Conflicts: # src/commands/stdlib/llm_invoke.ts # src/commands/stdlib/state.ts # src/runtime.ts # src/workflows/file.ts
|
Thanks for taking the merge and the provenance hardening — I had resolved the same conflict locally before I saw Verification of
Same 89 — all pre-existing on One finding, and it is When a cache write fails for a reason that is not cancellation, if (stateWasPublished && (signal?.aborted || afterStore || atomicWriteWasPublished(err))) {
Measured: one
With more retries it is more calls: the run pays once per attempt and keeps nothing. I have not touched it here; this branch neither causes it nor is the right place to fix it. Happy to open an issue with the reproducer, or to send a PR narrowing that condition to |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2dbf6984df
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| result: WorkflowStepResult; | ||
| }; | ||
| parallelBranchResults = { [winner.branchId]: winner.result }; | ||
| branchLedgers.get(winner.branchId)?.release(); |
There was a problem hiding this comment.
Release spend from failing wait:any branches
When a wait: "any" pipeline branch is the first to settle by failing after an llm.invoke call, such as llm.invoke ... | exec false, and the step uses on_error: continue or skip_rest, its LLM charge is still sitting in that branch's BranchSpendBuffer. Only a fulfilled winner reaches this release, so the error handler later settles the parent ledger and misses the buffered provider call; _meta.cost and cost_limit can then allow subsequent steps even though the failed branch already spent the budget.
Useful? React with 👍 / 👎.
Closes #133
Additional instructions
MUST: Keep Allow edits from maintainers enabled for this PR so maintainers
can help update the branch when needed.
What Problem This Solves
Fixes an issue where workflows that ask
llm.invokethe same question more than once would be charged for every repetition, even though only the first one reached a model. The second and later steps are served from the run state or the file cache, but the run is billed as if each had called the provider.Two things go wrong for the operator:
_meta.costreports a multiple of what the run actually spent.cost_limittrips early. Withaction: stopthe run is aborted mid-way and the remaining steps never run; withaction: warnthe operator is told a budget was exceeded that was not.The cache exists to save money, and the accounting was cancelling that saving out.
Why This Change Was Made
A cache or run-state hit re-emits the stored item verbatim, and that item still carries the
usageof the call that produced it.trackStepCostrecorded anyusageit found, with no notion of where the item came from.The fix skips items that
llm.invokere-emitted from run state or the response cache — the only two paths that return a stored result. Everything else, including live calls through every adapter, is billed exactly as before.Token totals are skipped along with the cost rather than zeroed, because a replay consumes no tokens either; reporting them as spent would be the same error one field over.
The replay is marked where it happens rather than inferred from the item. Neither
sourcenorcachedcan carry that: a direct adapter'ssourcedefaults to its provider name (llm_invoke.ts:549) andresolveProvideraccepts any key registered inctx.llmAdapters(:512), so a live call through an adapter namedcachelooks exactly like a replay under either field — andcachedis alreadytruefor it, beingtruefor any source outside the known remote list (:908). Inferring fromsourcewould have dropped those live calls from cost tracking, which is the same bug one direction over.The flag is set when an item is replayed, not when it is stored, so cache entries already on disk are covered without invalidation.
The exemption is keyed to provenance the command attaches in this process, not to any field in the item's JSON.
trackStepCostreads the parsed stdout of every step, shell steps included, so a marker made of JSON fields is only as trustworthy as the least trusted command in the workflow: a step that printed a replay-shaped object beside a realusageobject would drop its own spend out of_meta.costand could walk a run past a configuredcost_limit.llm.invokenow stamps a replayed item with a module-private symbol key, whichJSON.parsecannot produce andJSON.stringifydoes not emit, so only objects built inside this process are exempt and the serialized output is byte-identical. The publicreplayed: truefield stays as reporting for consumers, but nothing keys off it.A symbol survives the pipeline but not a renderer, and
llm.invoke | jsonis a supported step shape. The renderer prints its items to stdout and returns an empty stream, so the step is left with no pipeline items and its JSON is parsed back out of that text — where no symbol can be. Cached and run-state replays behind a renderer were billed again, with the same two consequences as before.Provenance is preserved rather than recovered. Each pipeline stage's renderer now records the objects it was handed, the pipeline returns them alongside its items, and a step whose pipeline produced no items carries them on a non-enumerable symbol key on its result. Cost accounting reads those in preference to the re-parsed JSON. The side channel holds only objects this process built and is never serialized, so the forgery guarantee above is unchanged: a step that prints the full replay shape is billed whether or not a renderer is in the way.
tableneeds no equivalent — it writes non-JSON to stdout, so there is nothing fortrackStepCostto parse.A projection loses it the same way.
pick model,usagebuilds a new object out of named fields, so a replayed item's own mark does not reach accounting — but the usage record crosses by reference, and the usage record is what gets billed. Both are marked, so the exemption survives an in-process projection without accounting having to recognize every command that can construct a new object.where,head,sortanddedupeyield the original item and were already covered;group_byandmap --wrapnest the usage where nothing bills it, andtemplateemits strings.An exemption also has to know whether the call being replayed was ever billed. A workflow records a step's cost only once the step succeeds, but
llm.invokepersists its answer before it returns — so a step that fails after its LLM call and is retried never bills the live item, and the retry replays that stored answer. Skipping it unconditionally reported $0 for a provider call that really happened, which is the original defect with its sign flipped.Provenance now carries the cache key alongside the replay flag, a live call opens a charge against that key, and accounting claims it. The claim succeeds for exactly one of the live item and its replays, so a provider call is billed exactly once however many steps re-emit its answer: the retried step's real spend is recorded, and an ordinary replay stays exempt. A charge belongs to the run that opened it. The ledger is created per workflow run, next to its
CostTracker, and reaches commands through the pipeline context, so only that run can recover a charge from a replay. A workflow that replays a cache entry written by an earlier run — or by an SDK caller outside cost accounting — finds nothing to claim and is billed nothing, and a live call made with no ledger in context opens no charge at all. Without that scope the ledger would move one run's spend onto another's_meta.costandcost_limit. The per-run lifetime also bounds it; a 256-entry cap remains as a backstop for a single long run.Charges are counted, not flagged. Two identical calls that race on a cold cache — two parallel branches asking the same question — are two provider charges under one cache key, so recording presence would let a retry settle one of them and drop the other.
recordincrements the count for a key andclaimdecrements it while it is above zero, so N live calls can be settled N times and never fewer, while a replay still settles at most one charge.One limit is worth stating plainly, because it predates this PR and survives it: a failed attempt whose spend nothing re-emits cannot be recovered.
--refreshbypasses run state and the cache, so a retried refresh makes a fresh call and is billed as live, but the failed attempt's own refresh call has no replay to ride in on and no accounting hook ever sees it.mainhas the same gap; closing it would mean billing from inside the command rather than from the step result.A gate pauses a run; it does not reset what the run has spent. The resume is a separate run with its own tracker and its own ledger, so a call made before the gate lives only in the paused run's record — and a later step repeating that prompt replays it and correctly bills nothing, which would leave the call in no total at all. The paused run now stores its cost summary in the resume state and the resuming run seeds its
CostTrackerfrom it, at all three pause points (approval, workflow input, pipeline input). The ledger is deliberately not seeded: after the resume that charge is settled, so its replay must stay exempt.This also closes a hole the PR did not create.
mainbuilds a freshCostTrackeron resume too, socost_limitapplied to the steps after the last pause rather than to the run, and a budget could be walked past one gate at a time; it only looked survivable because a post-resume replay happened to re-bill one earlier call. Stored entries are rebuilt through the same normalization as live usage rather than trusted verbatim, and a resume state written before this change has no cost field and seeds nothing, so existing resume tokens keep working.A pipeline can also pause between paying for a call and billing it.
llm.invoke | asksuspends in tool mode after the model has answered, andaskconsumes the item that carried the usage, so the step reports none: the charge exists only as an outstanding entry in the run's ledger. Those outstanding charges therefore travel in the resume state alongside the cost summary and reopen on resume. It is the same run continuing, so the per-run scoping is intact — a charge is still settled only by the run that opened it.A charge is opened when the provider answers, not once the answer is stored.
persistOutputsor the cache write can fail after run state already holds a replayable copy — an unwritableLOBSTER_CACHE_DIR, a full disk — and the retry then succeeds from that replay, which must still find a charge to settle. The reverse exposure is cheap: a store that fails leaves an open charge nothing claims, and an unclaimed charge is never billed.User Impact
A workflow that repeats an
llm.invokecall now reports the cost of the calls it really made, and acost_limitreflects real spend. Runs that were being aborted below their budget complete. No configuration change is needed, and workflows without caching behave identically.Evidence
Real-adapter proof
llm.invokeships no built-in provider, so "a real run" here means the shipped CLI talking to a real model through a bridge onLOBSTER_LLM_ADAPTER_URL— a local Ollama server (0.32.3,qwen3.5:9b). The bridge logs every request it receives, so the number of model calls comes from the adapter side rather than from an assertion.The workflow asks the same question three times, so steps 2 and 3 hit the cache:
One call is
(23 * 3 + 4 * 15) / 1e6 = $0.000129, comfortably inside the$0.0002budget.main@0ac962e— aborted:{ "protocolVersion": 1, "ok": false, "error": { "type": "runtime_error", "message": "Cost limit exceeded: $0.0003 > $0.00 limit" } }Exit code 1, with exactly one request on the adapter for that run:
This branch — completes:
{ "protocolVersion": 1, "ok": true, "status": "ok", "output": [ { "kind": "llm.invoke", "runId": "ollama_4", "model": "qwen3.5:9b", "output": { "format": "text", "text": "Dolphin", "data": null }, "usage": { "inputTokens": 23, "outputTokens": 4, "totalTokens": 27 } } ] }Exit code 0, again one adapter request:
The same three-step workflow with
action: warnand a floor budget makes the difference numeric. Both runs made one model call:mainfirst[WARN] Cost $0.0001 exceeds limit $0.00[WARN] Cost $0.0001 exceeds limit $0.00second[WARN] Cost $0.0002 exceeds limit $0.00[WARN] Cost $0.0001 exceeds limit $0.00third[WARN] Cost $0.0003 exceeds limit $0.00[WARN] Cost $0.0001 exceeds limit $0.00mainreports$0.000387for$0.000129of spend. The branch reports what was spent.The generated text is not the claim — sampling is stochastic and the two runs answered "Jellyfish" and "Dolphin". The claims are the adapter request count and the reported cost.
Rendered pipelines
Same bridge and model, three steps of
llm.invoke ... | json, one model call in every arm — the bridge log is the witness, and each arm gets a fresh cache directory. Pricing is set withLOBSTER_LLM_PRICING_JSONto{"qwen3.5:9b":{"input":100,"output":100}}so the numbers are large enough to read.With
action: warnand a floor budget:first[WARN] Cost $0.0023 exceeds limit $0.00[WARN] Cost $0.0021 exceeds limit $0.00second[WARN] Cost $0.0046 exceeds limit $0.00[WARN] Cost $0.0021 exceeds limit $0.00third[WARN] Cost $0.0069 exceeds limit $0.00[WARN] Cost $0.0021 exceeds limit $0.00Adding
| jsonto each step was enough to lose the exemption entirely: the left column is the pre-fixmainbehaviour, on a branch that already exempted the same replays without a renderer.With
cost_limit: { max_usd: 0.0034, action: stop }and a fourth step that echoes a marker:Previous revision — aborted at
second:Exit code 1, one request on the adapter:
This revision — completes:
[ "REACHED-END\r\n" ]Exit code 0, again one adapter request:
Output token counts differ between arms because each arm makes its own real call and sampling is stochastic; the budget is chosen so that one call passes and two fail across that whole range. The claims are the adapter request count and the reported cost, not the generated text.
Projections
Same setup, three steps of
llm.invoke ... | pick model,usage, one model call in every arm.35374f4afa93c9first[WARN] Cost $0.0022 exceeds limit $0.00[WARN] Cost $0.0023 exceeds limit $0.00second[WARN] Cost $0.0044 exceeds limit $0.00[WARN] Cost $0.0023 exceeds limit $0.00third[WARN] Cost $0.0066 exceeds limit $0.00[WARN] Cost $0.0023 exceeds limit $0.00With
max_usd: 0.0034, action: stopand a trailing marker step,35374f4exits 1 withCost limit exceeded: $0.0066 > $0.00 limitwhileafa93c9exits 0 and reaches the marker. One adapter request in both:Retried steps
Same bridge and model. One workflow: a step with
retry: { max: 2 }whose parallel branches are anllm.invokeand a command that fails the first time it runs, then two plainllm.invokesteps with the same prompt, then a marker step. The provider is called once in every arm.action: warn, running total per stepaction: stop, floor budgetmain@0ac962e$0.0237→$0.0474→$0.0711afa93c9(previous revision)$0.0238, flat across all four stepsmainbills the replay the retry produced, which records the failed attempt's spend for the wrong reason; the previous revision of this branch exempted that replay and lost the spend entirely. This revision bills it once and keeps the plain replays exempt. Costs differ between arms only because each arm makes its own real call and sampling is stochastic; the claims are the request count and the shape of the running total.Regression tests
Twenty-two tests in
test/cost_tracker.test.ts. The first seven landed with the previous revision. Againstmain, with both production filesreverted and the tests kept:
Tests 20 to 22 are the defect. Tests 23 to 26 pass on
mainbecausemainbills everyusage-bearing item, which is what they assert should still happen.
With the fix:
Tests 20 to 22 exercise the real
llm.invokecommand through a workflowpipeline:step against a local HTTP provider, and the replay is established from the provider's side rather than from an assertion: two identical steps, one request recorded by the server. Test 21 covers the run-state path the same way.The rest are controls rather than demonstrations. Test 23: two live calls in one run still trip the same limit, so the change removes a false positive rather than the enforcement. Test 24: two live steps whose
sourcehappens to becacheandrun_stateare still billed, which fails against an earlier revision of this branch that inferred the replay fromsource. Tests 25 and 26 are the forgery cases: a step that prints every field a replayed item carries, includingreplayed: true,cached: true, thellm.invokekind,cacheKey,status,createdAtandsource, alongside a realusageobject. Its spend is billed, and it cannot walk a run pastcost_limit.Mutation, to show the added coverage is load-bearing rather than self-confirming: with both production files restored to the previous revision of this branch and the new tests kept, exactly tests 25 and 26 fail and nothing else moves — the field-shaped marker accepts the forged object, while the pipeline replays in tests 20 to 22 stay green because a genuine item satisfies that shape too. That difference is the whole delta of this revision.
This revision adds five more, tests 27 to 31, for the rendered shape. Against the previous revision of this branch, with both production files reverted and the new tests kept:
Test 27 fails with
2000 !== 1000while the provider recorded one request. Tests 30 and 31 pass there and here: they run a step that prints the full replay shape throughexec --json=true node <file> | json, so the forged object reaches accounting by the same rendered path, and it is still billed and still tripscost_limit. All five pass with the fix.Three more, tests 32 to 34, for the projected shape. Against
35374f4with them kept, exactly 32 and 33 fail:Test 34 sends a forged replay object through
exec --json=true node <file> | pick model,usageand again through... | pick model,usage | json, so the projected and the projected-then-rendered paths both stay billed. All three pass with the fix.Two more, tests 35 and 36, for the retried shape —
_meta.costafter a retried step, andcost_limit: stopcounting it. Againstafa93c9with them kept, both fail (undefined !== 1000, and a missing rejection) while the provider recorded one request:Both also pass against
main, which bills every usage-bearing item: they assert the spend is recorded, not that the replay is exempt. Tests 20 to 34 stay green with the fix, so neither property is traded for the other.One more, test 37, for the ownership of a charge: run 1 makes a live call inside a step that fails permanently, so the charge is never recovered, and run 2 replays the same cache entry in the same process. Run 2 called no provider, so it must record no cost. Against the previous revision — where the ledger was process-global — it fails, with run 2's
_meta.costpopulated from run 1's call:The same test covers a live call made outside any workflow, such as the SDK pipeline API, whose cache entry a workflow later reuses: neither opens a charge a foreign run can claim.
And test 38 for the racing case, made deterministic rather than left to timing: the fake provider holds its answers until both requests are in flight, so two identical
llm.invokebranches genuinely miss a cold cache before the step fails and is retried. Both replays must be billed — two provider requests,2000input tokens, both branch ids inbyStep. Against the previous revision, where the ledger recorded presence rather than a count, it fails:with
1000 !== 2000while the provider recorded two requests: one real charge silently dropped.And test 39 for the pause:
llm.invoke→ approval gate → the samellm.invoke, paused and then resumed as a separate run. One provider request, and the resumed run must report the pre-gate call —1000/500tokens attributed to the step that made it. Against the previous revision it fails withundefined !== 1000: the call is in no total at all.And test 40 for the mid-pipeline pause:
llm.invoke ... | ask --prompt Continue?in tool mode, resumed with a response, then a second step repeating the prompt. One provider request, and the run must report1000/500— the replay is the only carrier left onceaskhas swallowed the item.And test 41 for that ordering:
LOBSTER_CACHE_DIRpoints at a plain file so the cache write throws after run state holds the answer, the step fails, and the retry succeeds from the replay. The run must still report the call.Suite, types, format
Same Windows machine, same command for both:
Twenty-two added tests. Eight of them pass on this machine; the fourteen that drive a real
llm.invokethrough a
pipeline:step (20 to 22, 27 to 29, 32 to 33, and 35 to 40) write a cache or state directory first, andensureDirectorythrows on Windows until #126 lands — which is the whole difference betweenthe second and third rows, along with the 33 other tests that fix recovers. On macOS and Linux
the second row does not apply. The 107 failures on
mainare unchanged by this branch, andnothing here touches
src/state/store.ts.tsc --noEmitclean;oxfmt --checkreports all 118 files correct;oxlintadds no new warnings.One note for whoever reads the tests: the step payloads are written to a file and run as
node <file>instead ofnode -e "…". On Windows the runner spawnscmd /d /s /c, which passes the inline quoting through verbatim, so anode -e "…"step emits nothing and its assertions cannot run. That is also why five of the existing workflow-level cost tests are red on this machine before and after this change.