Skip to content

fix: workflows are charged for cached model answers and stop on a budget they never spent - #134

Merged
steipete merged 32 commits into
openclaw:mainfrom
Yigtwxx:fix/workflow-cost-skip-cached-replays
Aug 13, 2026
Merged

fix: workflows are charged for cached model answers and stop on a budget they never spent#134
steipete merged 32 commits into
openclaw:mainfrom
Yigtwxx:fix/workflow-cost-skip-cached-replays

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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.invoke the 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.cost reports a multiple of what the run actually spent.
  • cost_limit trips early. With action: stop the run is aborted mid-way and the remaining steps never run; with action: warn the 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 usage of the call that produced it. trackStepCost recorded any usage it found, with no notion of where the item came from.

The fix skips items that llm.invoke re-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 source nor cached can carry that: a direct adapter's source defaults to its provider name (llm_invoke.ts:549) and resolveProvider accepts any key registered in ctx.llmAdapters (:512), so a live call through an adapter named cache looks exactly like a replay under either field — and cached is already true for it, being true for any source outside the known remote list (:908). Inferring from source would 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. trackStepCost reads 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 real usage object would drop its own spend out of _meta.cost and could walk a run past a configured cost_limit. llm.invoke now stamps a replayed item with a module-private symbol key, which JSON.parse cannot produce and JSON.stringify does not emit, so only objects built inside this process are exempt and the serialized output is byte-identical. The public replayed: true field stays as reporting for consumers, but nothing keys off it.

A symbol survives the pipeline but not a renderer, and llm.invoke | json is 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. table needs no equivalent — it writes non-JSON to stdout, so there is nothing for trackStepCost to parse.

A projection loses it the same way. pick model,usage builds 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, sort and dedupe yield the original item and were already covered; group_by and map --wrap nest the usage where nothing bills it, and template emits 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.invoke persists 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.cost and cost_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. record increments the count for a key and claim decrements 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. --refresh bypasses 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. main has 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 CostTracker from 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. main builds a fresh CostTracker on resume too, so cost_limit applied 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 | ask suspends in tool mode after the model has answered, and ask consumes 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. persistOutputs or the cache write can fail after run state already holds a replayable copy — an unwritable LOBSTER_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.invoke call now reports the cost of the calls it really made, and a cost_limit reflects 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.invoke ships no built-in provider, so "a real run" here means the shipped CLI talking to a real model through a bridge on LOBSTER_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:

cost_limit:
  max_usd: 0.0002
  action: stop
steps:
  - id: first
    pipeline: llm.invoke --prompt "Name one sea creature. Answer with just the name." --model qwen3.5:9b
  - id: second
    pipeline: llm.invoke --prompt "Name one sea creature. Answer with just the name." --model qwen3.5:9b
  - id: third
    pipeline: llm.invoke --prompt "Name one sea creature. Answer with just the name." --model qwen3.5:9b

One call is (23 * 3 + 4 * 15) / 1e6 = $0.000129, comfortably inside the $0.0002 budget.

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:

[bridge] 2026-08-03T16:23:59.049Z request #3 received model=qwen3.5:9b promptChars=49
[bridge] 2026-08-03T16:23:59.716Z request #3 model answered after 667ms

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:

[bridge] 2026-08-03T16:28:21.707Z request #4 received model=qwen3.5:9b promptChars=49
[bridge] 2026-08-03T16:28:22.422Z request #4 model answered after 715ms

The same three-step workflow with action: warn and a floor budget makes the difference numeric. Both runs made one model call:

after step main this branch
first [WARN] Cost $0.0001 exceeds limit $0.00 [WARN] Cost $0.0001 exceeds limit $0.00
second [WARN] Cost $0.0002 exceeds limit $0.00 [WARN] Cost $0.0001 exceeds limit $0.00
third [WARN] Cost $0.0003 exceeds limit $0.00 [WARN] Cost $0.0001 exceeds limit $0.00

main reports $0.000387 for $0.000129 of 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 with LOBSTER_LLM_PRICING_JSON to {"qwen3.5:9b":{"input":100,"output":100}} so the numbers are large enough to read.

With action: warn and a floor budget:

after step previous revision of this branch this revision
first [WARN] Cost $0.0023 exceeds limit $0.00 [WARN] Cost $0.0021 exceeds limit $0.00
second [WARN] Cost $0.0046 exceeds limit $0.00 [WARN] Cost $0.0021 exceeds limit $0.00
third [WARN] Cost $0.0069 exceeds limit $0.00 [WARN] Cost $0.0021 exceeds limit $0.00

Adding | json to each step was enough to lose the exemption entirely: the left column is the pre-fix main behaviour, 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:

Error: Cost limit exceeded: $0.0066 > $0.00 limit

Exit code 1, one request on the adapter:

REQUEST #1 model=qwen3.5:9b prompt="Name one sea creature."
  -> ollama usage in=17 out=16

This revision — completes:

[
  "REACHED-END\r\n"
]

Exit code 0, again one adapter request:

REQUEST #1 model=qwen3.5:9b prompt="Name one sea creature."
  -> ollama usage in=17 out=5

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.

after step 35374f4 afa93c9
first [WARN] Cost $0.0022 exceeds limit $0.00 [WARN] Cost $0.0023 exceeds limit $0.00
second [WARN] Cost $0.0044 exceeds limit $0.00 [WARN] Cost $0.0023 exceeds limit $0.00
third [WARN] Cost $0.0066 exceeds limit $0.00 [WARN] Cost $0.0023 exceeds limit $0.00

With max_usd: 0.0034, action: stop and a trailing marker step, 35374f4 exits 1 with Cost limit exceeded: $0.0066 > $0.00 limit while afa93c9 exits 0 and reaches the marker. One adapter request in both:

REQUEST #1 model=qwen3.5:9b prompt="Name one sea creature."
  -> ollama usage in=17 out=16

Retried steps

Same bridge and model. One workflow: a step with retry: { max: 2 } whose parallel branches are an llm.invoke and a command that fails the first time it runs, then two plain llm.invoke steps with the same prompt, then a marker step. The provider is called once in every arm.

revision action: warn, running total per step action: stop, floor budget
main @ 0ac962e $0.0237$0.0474$0.0711 exit 1
afa93c9 (previous revision) no cost recorded at all exit 0 — the run finishes having spent real money
this revision $0.0238, flat across all four steps exit 1

main bills 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. Against main, with both production files
reverted and the tests kept:

$ node --test dist/test/cost_tracker.test.js
not ok 20 - workflow cost tracking bills a cached llm.invoke replay only once
not ok 21 - workflow cost tracking bills a run-state llm.invoke replay only once
not ok 22 - cost_limit stop is not tripped by a replayed llm.invoke
ok 23 - cost_limit stop still trips on repeated live calls
ok 24 - workflow cost tracking bills a live call from an adapter named like a replay source
ok 25 - workflow cost tracking bills a step that prints the full replay shape
ok 26 - cost_limit stop cannot be bypassed by a step that prints the full replay shape

Tests 20 to 22 are the defect. Tests 23 to 26 pass on main because main bills every
usage-bearing item, which is what they assert should still happen.

With the fix:

ok 20 - workflow cost tracking bills a cached llm.invoke replay only once
ok 21 - workflow cost tracking bills a run-state llm.invoke replay only once
ok 22 - cost_limit stop is not tripped by a replayed llm.invoke
ok 23 - cost_limit stop still trips on repeated live calls
ok 24 - workflow cost tracking bills a live call from an adapter named like a replay source
ok 25 - workflow cost tracking bills a step that prints the full replay shape
ok 26 - cost_limit stop cannot be bypassed by a step that prints the full replay shape

Tests 20 to 22 exercise the real llm.invoke command through a workflow pipeline: 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 source happens to be cache and run_state are still billed, which fails against an earlier revision of this branch that inferred the replay from source. Tests 25 and 26 are the forgery cases: a step that prints every field a replayed item carries, including replayed: true, cached: true, the llm.invoke kind, cacheKey, status, createdAt and source, alongside a real usage object. Its spend is billed, and it cannot walk a run past cost_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:

not ok 27 - workflow cost tracking bills a cached llm.invoke replay only once behind a renderer
not ok 28 - workflow cost tracking bills a run-state llm.invoke replay only once behind a renderer
not ok 29 - cost_limit stop is not tripped by a replayed llm.invoke behind a renderer
ok 30 - workflow cost tracking bills a rendered step that prints the full replay shape
ok 31 - cost_limit stop cannot be bypassed by a rendered step that prints the replay shape

Test 27 fails with 2000 !== 1000 while the provider recorded one request. Tests 30 and 31 pass there and here: they run a step that prints the full replay shape through exec --json=true node <file> | json, so the forged object reaches accounting by the same rendered path, and it is still billed and still trips cost_limit. All five pass with the fix.

Three more, tests 32 to 34, for the projected shape. Against 35374f4 with them kept, exactly 32 and 33 fail:

not ok 32 - workflow cost tracking bills a cached llm.invoke replay only once through a projection
not ok 33 - workflow cost tracking bills a run-state llm.invoke replay only once through a projection
ok 34 - workflow cost tracking bills a projected step that prints the full replay shape

Test 34 sends a forged replay object through exec --json=true node <file> | pick model,usage and 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.cost after a retried step, and cost_limit: stop counting it. Against afa93c9 with them kept, both fail (undefined !== 1000, and a missing rejection) while the provider recorded one request:

not ok 35 - workflow cost tracking bills a replay standing in for a retried step's live call
not ok 36 - cost_limit stop counts the live call a retried step's replay stands in for

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.cost populated from run 1's call:

not ok 37 - workflow cost tracking does not bill a replay of a call another run paid for

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.invoke branches genuinely miss a cold cache before the step fails and is retried. Both replays must be billed — two provider requests, 2000 input tokens, both branch ids in byStep. Against the previous revision, where the ledger recorded presence rather than a count, it fails:

not ok 38 - workflow cost tracking bills both live calls a retried step's replays stand in for

with 1000 !== 2000 while the provider recorded two requests: one real charge silently dropped.

And test 39 for the pause: llm.invoke → approval gate → the same llm.invoke, paused and then resumed as a separate run. One provider request, and the resumed run must report the pre-gate call — 1000/500 tokens attributed to the step that made it. Against the previous revision it fails with undefined !== 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 report 1000/500 — the replay is the only carrier left once ask has swallowed the item.

And test 41 for that ordering: LOBSTER_CACHE_DIR points 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:

main @ 0ac962e                    # tests 309   # pass 202   # fail 107
this branch                      # tests 331   # pass 210   # fail 121
this branch, with #126 applied   # tests 333   # pass 259   # fail  74

Twenty-two added tests. Eight of them pass on this machine; the fourteen that drive a real llm.invoke
through a pipeline: step (20 to 22, 27 to 29, 32 to 33, and 35 to 40) write a cache or state directory first, and
ensureDirectory throws on Windows until #126 lands — which is the whole difference between
the 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 main are unchanged by this branch, and
nothing here touches src/state/store.ts.

tsc --noEmit clean; oxfmt --check reports all 118 files correct; oxlint adds no new warnings.

One note for whoever reads the tests: the step payloads are written to a file and run as node <file> instead of node -e "…". On Windows the runner spawns cmd /d /s /c, which passes the inline quoting through verbatim, so a node -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.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/workflows/file.ts Outdated
}

function isReplayedResult(item: Record<string, unknown>) {
return item.source === "cache" || item.source === "run_state";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P1 Urgent regression or broken agent/channel workflow affecting real users now. labels Aug 3, 2026
@clawsweeper

clawsweeper Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codex review: needs changes before merge. Reviewed August 8, 2026, 5:28 PM ET / 21:28 UTC.

ClawSweeper review

What this changes

The 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 provenance

Possible 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
Reviewed head: a91734fbd18d0dcbf0590e016615525ab8951040

Review scores

Measure Result What it means
Overall readiness 🧂 unranked krab (1/6) The central behavior proof is strong, but the current P1 budget-bypass defect makes the patch not merge-ready.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (logs): The PR body provides before/after shipped-CLI traces and adapter-side request counts for the central cached-replay behavior.
Patch quality 🧂 unranked krab (1/6) Security review found an item that needs attention.

Verification

Check Result Evidence
Real behavior Verified Sufficient (logs): The PR body provides before/after shipped-CLI traces and adapter-side request counts for the central cached-replay behavior.
Evidence reviewed 6 items Current-main behavior: Current main records every step JSON usage record without distinguishing cache or run-state replays, which establishes the bug this PR is intended to repair.
Public-key suppression: An unmarked JSON result is deferred into billCopy; after a matching charge was billed, this return value suppresses the later usage using only public cache key, model, and token fields.
Untrusted workflow input reaches the predicate: The accounting path accepts every JSON-producing workflow result, including shell or external-process output, then sends unmarked usage records with any public cache key to deferred copy accounting.
Findings 1 actionable finding [P1] Do not let public cache keys suppress later usage
Security Needs attention Public JSON can bypass workflow budget accounting: A later shell or external-process result can reuse public call fields to be treated as an already-billed copy, suppressing its usage record.

How this fits together

Lobster workflows run pipeline steps such as LLM invocations and aggregate their reported usage into _meta.cost and cost_limit. This change carries invocation provenance and per-run charges through pipeline transforms, persistence, and resume handling into that accounting.

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]
Loading

Before merge

  • Do not let public cache keys suppress later usage (P1) - This repeats the prior P1 at the same head: every workflow JSON result reaches billCopy, including shell and external-process output. Once a real charge is settled, an output that copies its public cacheKey, model, and billable token counts makes this return false, so its own usage is omitted and cost_limit can be bypassed. Require non-forgeable provenance for suppression instead.
  • Resolve security concern: Public JSON can bypass workflow budget accounting - A later shell or external-process result can reuse public call fields to be treated as an already-billed copy, suppressing its usage record.
  • Resolve merge risk (P1) - A shell step or external process can emit a previously visible cache key, model, and billable token counts, causing _meta.cost to omit its usage and allowing cost_limit: stop to be bypassed.
  • Improve patch quality - Remove public-key-based suppression and add the external-JSON regression.
  • Improve patch quality - Provide a redacted workflow transcript showing that the lookalike output remains billed and triggers the configured cost limit.

Findings

  • [P1] Do not let public cache keys suppress later usage — src/commands/stdlib/llm_invoke.ts:406
  • [medium] Public JSON can bypass workflow budget accounting — src/commands/stdlib/llm_invoke.ts:406
Agent review details

Security

Needs attention: The new accounting boundary lets untrusted JSON suppress recorded workflow spend.

Review metrics

Metric Value Why it matters
Production and test delta production +847/-23, tests +1,971 A narrow accounting bug fix has expanded into multiple persistence and workflow boundaries, so the remaining P1 must be resolved before merge.

Root-cause cluster

Relationship: fixed_by_candidate
Canonical: #133
Summary: This PR is the linked candidate fix for the cached LLM replay cost-accounting bug.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge-risk options

Maintainer options:

  1. Remove public-key suppression before merge (recommended)
    Require private provenance for any omitted usage and add a regression where a later external JSON result copies an earlier cache key and usage.
  2. Pause the branch
    Do not merge the accounting expansion until the budget-bypass path has a bounded repair and passing regression coverage.
Copy recommended automerge instruction
@clawsweeper automerge

Special instructions:
Add a regression where a later shell or exec JSON output copies a prior item's public cacheKey, model, and billable tokens; ensure it is billed and cost_limit stops, then restrict suppression to non-forgeable provenance.

Technical review

Best 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 cacheKey, model, and token fields from a shell or external process; the current predicate suppresses that later usage.

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:

  • [P1] Do not let public cache keys suppress later usage — src/commands/stdlib/llm_invoke.ts:406
    This repeats the prior P1 at the same head: every workflow JSON result reaches billCopy, including shell and external-process output. Once a real charge is settled, an output that copies its public cacheKey, model, and billable token counts makes this return false, so its own usage is omitted and cost_limit can be bypassed. Require non-forgeable provenance for suppression instead.
    Confidence: 0.99

Overall correctness: patch is incorrect
Overall confidence: 0.99

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 0ac962e90b78.

Labels

Label changes:

  • add merge-risk: 🚨 security-boundary: Untrusted shell or external-process JSON can currently obtain authority to suppress recorded spend.
  • remove merge-risk: 🚨 compatibility: Current PR review merge-risk labels are merge-risk: 🚨 availability, merge-risk: 🚨 security-boundary.

Label justifications:

  • P1: The current patch can let workflows run past a configured stop-on-cost limit.
  • merge-risk: 🚨 availability: Incorrect cost-limit accounting can permit later workflow steps to run when the configured budget should have stopped them.
  • merge-risk: 🚨 security-boundary: Untrusted shell or external-process JSON can currently obtain authority to suppress recorded spend.
  • rating: 🧂 unranked krab: Overall readiness is 🧂 unranked krab; proof is 🦞 diamond lobster and patch quality is 🧂 unranked krab.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (logs): The PR body provides before/after shipped-CLI traces and adapter-side request counts for the central cached-replay behavior.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body provides before/after shipped-CLI traces and adapter-side request counts for the central cached-replay behavior.

Evidence

Security concerns:

  • [medium] Public JSON can bypass workflow budget accounting — src/commands/stdlib/llm_invoke.ts:406
    A later shell or external-process result can reuse public call fields to be treated as an already-billed copy, suppressing its usage record.
    Confidence: 0.99

Acceptance criteria:

  • [P1] pnpm typecheck.
  • [P1] pnpm format:check.
  • [P1] pnpm test.
  • [P1] Run a tool-mode workflow proving the public-field lookalike is billed and the configured stop limit triggers.

What I checked:

  • Current-main behavior: Current main records every step JSON usage record without distinguishing cache or run-state replays, which establishes the bug this PR is intended to repair. (src/workflows/file.ts:2253, 0ac962e90b78)
  • Public-key suppression: An unmarked JSON result is deferred into billCopy; after a matching charge was billed, this return value suppresses the later usage using only public cache key, model, and token fields. (src/commands/stdlib/llm_invoke.ts:406, a91734fbd18d)
  • Untrusted workflow input reaches the predicate: The accounting path accepts every JSON-producing workflow result, including shell or external-process output, then sends unmarked usage records with any public cache key to deferred copy accounting. (src/workflows/file.ts:2544, a91734fbd18d)
  • Review continuity: The prior P1 targets this same public-key suppression, and the current reviewed head is unchanged at the affected paths. (src/commands/stdlib/llm_invoke.ts:406, a91734fbd18d)
  • Feature history: The original workflow cost-tracking feature appears to date to this commit, making its author the strongest current-main routing candidate. (src/workflows/file.ts:2242, 373c447e39d7)
  • Real behavior evidence: The PR body includes shipped-CLI before/after runs against a request-counting model adapter showing the central replay-cost failure and recovery. (a91734fbd18d)

Likely related people:

  • Vignesh Natarajan: Authored the main-history commit that introduced workflow LLM cost tracking and spending limits. (role: introduced behavior; confidence: high; commits: 373c447e39d7; files: src/workflows/file.ts)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (60 earlier review cycles; latest 8 shown)
  • reviewed 2026-08-08T04:35:11.979Z sha a91734f :: needs changes before merge. :: [P2] Preserve provenance past the state-write cap | [P2] Retain settled provenance across a full resumed run
  • reviewed 2026-08-08T05:45:24.813Z sha a91734f :: needs changes before merge. :: [P2] Keep state replay provenance past the write cap | [P2] Retain settled provenance across long resumed runs
  • reviewed 2026-08-08T06:02:41.091Z sha a91734f :: needs changes before merge. :: [P2] Preserve state replay provenance past the write cap | [P2] Retain settled provenance across full resumed runs
  • reviewed 2026-08-08T09:49:19.775Z sha a91734f :: needs changes before merge. :: [P2] Retain state replay provenance past the write cap | [P2] Preserve settled provenance across long resumed runs | [P2] Bill keyed JSON copies from the recorded charge
  • reviewed 2026-08-08T11:53:32.042Z sha a91734f :: needs changes before merge. :: [P2] Retain state replay provenance past the write cap | [P2] Preserve settled provenance across long resumed runs | [P2] Bill keyed JSON copies from their recorded provider charge
  • reviewed 2026-08-08T14:06:48.842Z sha a91734f :: needs changes before merge. :: [P2] Preserve provenance beyond the state-write cap | [P2] Preserve all settled charges across a resume | [P2] Price keyed JSON copies from their recorded charge | [P1] Do not let public JSON suppress a later usage record
  • reviewed 2026-08-08T16:06:20.587Z sha a91734f :: found issues before merge. :: [P1] Do not trust public cache keys to suppress later usage | [P2] Retain state provenance past the write cap | [P2] Retain settled provenance across long resumed runs | [P2] Settle keyed JSON copies from their recorded charge | [P2] Bound charges per call rather than only per key
  • reviewed 2026-08-08T18:08:26.794Z sha a91734f :: needs changes before merge. :: [P1] Do not trust public cache keys to suppress later usage

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 3, 2026
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.
@Yigtwxx

Yigtwxx commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

The finding is right, and it is the mirror of the bug this PR fixes: trackStepCost runs on every step's JSON, so a bare marker let any command that reports its own replay state beside a real usage object fall out of _meta.cost and cost_limit — under-reporting spend instead of over-reporting it.

Pushed f416939. The exemption is now gated on the normalized item that llm.invoke / llm_task.invoke actually re-emit, not on the marker alone:

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 cache also carries kind: "llm.invoke" and cached: true — and the marker alone does not close yours. Together, an exempt item has to be one Lobster itself re-emitted from run state or the response cache.

Two new tests, and both fail against the head you flagged (1d77392):

$ node --test --test-name-pattern "only looks replayed|outside the LLM contract" dist/test/cost_tracker.test.js
not ok 1 - workflow cost tracking bills command output that only looks replayed
not ok 2 - cost_limit stop cannot be bypassed by a replay marker outside the LLM contract
# pass 0
# fail 2

With f416939:

ok 1 - workflow cost tracking bills command output that only looks replayed
ok 2 - cost_limit stop cannot be bypassed by a replay marker outside the LLM contract
# pass 2
# fail 0

The first bills a step that emits { replayed: true, model, usage }; the second gives an unrelated kind the otherwise-complete item shape and confirms cost_limit still stops the run. The existing fixtures now carry cacheKey, status and createdAt, so they match what the command really emits rather than a reduced stand-in.

Genuine replays are unaffected — measured against a real model. Same three-step workflow through a local Ollama bridge, cold cache, cost_limit: {max_usd: 0.004, action: stop}, pricing pinned for the model:

main @ 0ac962e:

{"ok": false, "error": "Cost limit exceeded: $0.0052 > $0.00 limit"}
model requests: 1

this branch:

{"ok": true, "status": "ok"}
model requests: 1

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 1d77392 — two added tests, two added passes, the same pre-existing failures. tsc --noEmit clean; oxfmt --check bin src test clean.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Aug 3, 2026
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
@Yigtwxx

Yigtwxx commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

The P2 finding is fixed in 8ffb052. The replay exemption no longer reads anything out of step JSON, so all three before-merge items close together: the merge risk and the remaining repair were both consequences of that one predicate.

What was wrong

isReplayedLlmItem matched a fixed set of fields, and trackStepCost parses the stdout of every step. A shell step could print that shape beside a real usage object, drop its own spend out of _meta.cost, and walk a run past a configured cost_limit. The predicate was as trustworthy as the least trusted command in the workflow.

What changed

llm.invoke stamps a replayed item with a module-private Symbol at the two points that re-emit a stored result. JSON.parse cannot produce a symbol key and JSON.stringify does not emit one, so only objects built inside this process are exempt and the serialized output is byte-identical. The public replayed: true field stays as reporting for consumers, but nothing keys off it.

The symbol is Symbol(...), not Symbol.for(...), so it is not reachable through the global registry either.

The full-shape case

Two tests print every field a replayed item carries — replayed: true, cached: true, kind: "llm.invoke", cacheKey, status, createdAt, source — next to a real usage object:

ok 25 - workflow cost tracking bills a step that prints the full replay shape
ok 26 - cost_limit stop cannot be bypassed by a step that prints the full replay shape

Test 26 is the one that matters: the forged step runs after a live call inside a max_usd budget, and the run now stops as configured instead of continuing.

The exemption still works, established from the provider's side

Tests 20 to 22 drive the real llm.invoke command through a workflow pipeline: step against a local HTTP provider. The replay is not asserted, it is counted by the server:

workflow provider requests billed steps _meta.cost input tokens
two identical pipeline: llm.invoke steps, shared cache dir 1 ["live"] 1000
same, --disable-cache, shared run-state key 1 ["live"] 1000

Pipeline steps carry the item as an in-process object rather than re-parsed text, which is what lets provenance survive the trip to trackStepCost. I measured that rather than assuming it.

Mutation control

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. 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 Windows

Tests 20 to 22 need a cache or state directory, and on Windows they fail before reaching any assertion with ENOENT ... \llm.invoke\C:. That is the unrelated defect in syncCreatedDirectoryChain that #126 repairs: fs.mkdir(..., { recursive: true }) reports the first created directory as an extended-length path, path.relative between that and the plain drive path returns an absolute path, and its first segment C: gets appended to the chain. With #126 applied locally the three tests pass as shown above. Nothing in this PR depends on that change; it only blocks the local run.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/workflows/file.ts Outdated
// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@clawsweeper clawsweeper Bot removed the merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. label Aug 4, 2026
`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
@Yigtwxx

Yigtwxx commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

The JSON-renderer finding was right, and 35374f4 fixes it. Thanks — the previous head really did lose the exemption the moment a step ended in | json.

Why the symbol was not enough. json writes its items to stdout and returns an empty stream, so runPipelineStep finds no pipeline items and falls back to parseJson(renderedStdout). Those objects come out of JSON.parse, which cannot carry a symbol key, so every cached and run-state replay behind a renderer was billed again.

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, runPipeline returns them as renderedItems, and runPipelineStep attaches them to the step result on a non-enumerable symbol key — only when the pipeline itself produced no items, so the ordinary path is untouched. trackStepCost bills those in preference to the re-parsed JSON.

The forgery guarantee is unchanged, because the side channel holds only objects this process constructed and is never serialized. table needs no equivalent: it writes non-JSON to stdout, so parseJson returns nothing to bill in the first place.

Real-adapter evidence at the new head. Same counting bridge to a local Ollama qwen3.5:9b, three llm.invoke ... | json steps, a fresh cache directory per arm, and pricing forced with LOBSTER_LLM_PRICING_JSON. One model call in every arm, taken from the bridge log rather than an assertion.

after step 8ffb052 35374f4
first [WARN] Cost $0.0023 exceeds limit $0.00 [WARN] Cost $0.0021 exceeds limit $0.00
second [WARN] Cost $0.0046 exceeds limit $0.00 [WARN] Cost $0.0021 exceeds limit $0.00
third [WARN] Cost $0.0069 exceeds limit $0.00 [WARN] Cost $0.0021 exceeds limit $0.00

With max_usd: 0.0034, action: stop and a trailing marker step, 8ffb052 exits 1 with Cost limit exceeded: $0.0066 > $0.00 limit while 35374f4 exits 0 and reaches the marker — one adapter request either way.

Coverage. Five tests added, 27 to 31. Against 8ffb052 with the new tests kept, exactly 27 to 29 fail (27 with 2000 !== 1000 while the provider logged one request); 30 and 31 pass there and here, driving a forged replay object through exec --json=true node <file> | json so it reaches accounting by the same rendered path, where it is still billed and still trips cost_limit.

Suite on this Windows machine: 321 / 208 / 113, against 316 / 206 / 110 for the previous head — the three new failures are 27 to 29, which write a cache or state directory and need #126 to land here. With #126 applied: 321 / 247 / 74. tsc --noEmit clean, oxfmt --check clean on all 118 files, oxlint adds no new warnings. The PR body has the full transcript.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Aug 4, 2026
@clawsweeper

clawsweeper Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Aug 5, 2026
`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
@Yigtwxx

Yigtwxx commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in d9b111b. The P2 was right, and I was wrong to route it to a maintainer twice: underneath the semantic question there is a plain regression this branch introduces, and it does not need a policy call to fix.

I had been reading "define the cancellation/settlement contract" as only the drain-or-not question. Measuring it instead. One wait: "any" step with a fast and a slow llm.invoke branch, then a tail step whose length decides how long the run lives after the race — all three calls really happen in every row:

tail step this branch (9396998) main (0ac962e)
returns immediately _meta.cost 2000 tokens, byStep: ["fast","tail"] 2000, ["fast","tail"]
still running 700ms later 3000, ["fast","tail","slow"] 2000, ["fast","tail"]

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. main is deterministic — a loser is never billed there, because its result is discarded before anything accounts for it. The run-scoped ledger is what made a late record reachable, so this is the ledger's regression, not a boundary it merely exposed. cost_limit inherits the same coin flip.

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 branchAbortController.abort(). A discarded branch's output is thrown away and its accounting goes with it, which is exactly what main already did.

Reads pass straight through. That is safe rather than lucky: inside a pipeline the only ledger call is record (llm_invoke.ts:496) — claim and billCopy are workflow-level and run after a branch has returned, never from inside one, so a branch can neither settle a charge the run opened before the race nor consume one another branch is standing on. wait: "all" keeps every branch and its result, so it passes llmSpendLedger unchanged and bills as before.

Regression test: workflow cost tracking leaves a discarded wait:any branch out of the total in test/cost_tracker.test.ts, driving a real workflow through runWorkflowFile against the fake provider. It runs the same race twice, short tail and long tail, and asserts both report 2000 with byStep: ["fast","tail"] while the provider still counts 3 requests. Against 9396998 it fails with 3000 !== 2000.

Suite with #126 applied, same command both sides: 362 / 281 pass / 81 fail before, 363 / 282 pass / 81 fail here — the one added test, and the same 81 Windows baseline failures. pnpm typecheck and format:check clean; oxlint reports only its pre-existing warnings.

What is still open, and I am not deciding it here: whether wait: "any" should drain losing branches before the step settles, so their spend is billed rather than discarded. That one really is a product call — it changes wait-any latency — and it is a question about main's behavior, which this branch now matches instead of varying from run to run. Happy to file it separately.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Aug 7, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/workflows/file.ts Outdated
Comment on lines +2520 to +2523
llmSpendLedger.claim(provenance.cacheKey, {
model,
usage: usage as Record<string, unknown>,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. merge-risk: 🚨 other 🚨 Merging this PR has meaningful risk outside the owned taxonomy. labels Aug 7, 2026
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
@Yigtwxx

Yigtwxx commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in a91734f — the P2 was right, and both halves of it reproduce.

The item that reaches trackStepCost has already been through the step's pipeline, so what it says about the call is whatever the last stage left behind. Measured against d9b111b, one live gpt-4o call costing 1000 in / 500 out (real price $0.0075):

step pipeline what reaches accounting billed
llm.invoke --model gpt-4o … item's own mark, model intact $0.0075 — correct
… | pick usage projection drops the item's mark and its model; the marked usage record still crosses by reference $0model = null, so recordUsage prices it at {input: 0, output: 0}
… | map model=gpt-3.5-turbo spread keeps the enumerable provenance symbol, model rewritten $0.00125 — a 6× under-charge at another model's rate

cost_limit reads both as room left, which is the part that matters: the second one is not a reporting cosmetic, it lets a run continue past a budget it has already spent.

The fix. claim() already hands back the charge the call opened, and the deferred replay path is billed from it precisely because the carrier is not a reliable witness of the cost. The live path now does the same: when the settled charge carries usage, the step is priced from its model/usage; a charge with no cost — restored from resume state written before charges carried one — still falls back to the item, so nothing that used to be billed stops being billed. +12 lines, no other call path touched.

Regressions added in test/cost_tracker.test.ts, both driving a real workflow through runWorkflowFile against the fake provider:

  • workflow cost tracking prices a projected live call from the charge it settled — fails against d9b111b with 0 !== 0.0075
  • workflow cost tracking prices a live call whose model field a step rewrote — fails against d9b111b with 0.00125 !== 0.0075

Suite 365 / 225 pass / 140 fail against a 363 / 223 / 140 baseline on the same tree and command — the 140 is this Windows machine's unchanged baseline (it is the ENOENT ...\C: directory bug #126 fixes, not a failure this adds). tsc --noEmit clean, oxfmt --check clean; the only oxlint warnings are on pre-existing lines.

One note for the record, not a defence: main gets both of these wrong too — trackStepCost at file.ts:2242 reads item.model and item.usage verbatim and has no charge to consult. This branch is the first version that holds one, which is why the repair belongs here rather than in a separate PR.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +386 to +387
const isSameCall = (charge: LlmChargeCost) =>
(charge.model ?? null) === model && sameBillableUsage(charge.usage, usage);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Yigtwxx

Yigtwxx commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

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 gpt-4o call through a stub OpenClaw provider, usage: {inputTokens: 1000, outputTokens: 500} — $0.0075 for the one provider request. provider_requests is counted at the server in every row.

# workflow main (0ac962e) this branch (a91734f) branch with public-key suppression removed
A live step, then a cached replay handed through an external process (| exec --stdin json --json=true node relay.cjs) $0.015["live","replay-roundtrip"] $0.0075 — ["live"] $0.015["live","replay-roundtrip"]
B live step, then a shell step printing that item's exact JSON $0.015 $0.0075 — suppressed $0.015
C same forgery, outputTokens 501 instead of 500 $0.01501 $0.01501 — billed
D live step, then a shell step printing the same JSON without usage $0.0075 $0.0075

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 usage and is billed nothing. That is trackStepCost's first guard (src/workflows/file.ts:2497) and it behaves identically on main. The branch grants no evasion that main does not already grant; what B suppresses is the forger's own invented record, not spend a provider was ever asked for.

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 test/cost_tracker.test.ts the same removal takes the suite from 70 pass / 5 fail to 64 / 11 (five failures pre-existing on this Windows box without #126).

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: billCopy bills on cost alone but will only withhold on key and cost (src/commands/stdlib/llm_invoke.ts:365-406), which is asymmetric on purpose — settling only adds a record, withholding removes one. README bounds the exemption at the process edge, and the behaviour has direct coverage at test/cost_tracker.test.ts:2144-2147.

Happy to take it either way, but it is your call which loss to accept, so I would rather not choose it for you:

  1. Keep it as is — a lookalike that matches a real call's key and cost to the token suppresses its own invented record; anyone who wanted that could omit usage instead.
  2. Drop public-key suppression — every forgery is billed, and a real cached replay crossing a process boundary is billed twice.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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
@Yigtwxx

Yigtwxx commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking the merge and the provenance hardening — restoreLlmProvenance at the resume-state boundary is a better answer than the key-based suppression it replaces, and it settles the P1 without the "non-forgeable marker" problem: nothing a step prints reaches that helper.

I had resolved the same conflict locally before I saw 2dbf698, and threw mine away — yours is the better resolution. Two things it does that mine did not: cost/llmSpend/llmBilled at all three save sites (mine dropped the approval one), and the ENOTDIR miss in readCacheEntry.

Verification of 2dbf698 on Windows 11 / Node 22.20, with #126 applied as a working-tree patch (without it every test that creates a state dir fails for an unrelated reason):

head tests pass fail
f2438f5 (main) 449 346 89
2dbf698 (this branch) 506 403 89

Same 89 — all pre-existing on main (POSIX file modes, SIGTERM to descendant processes, and the cost_limit warn/stop group).


One finding, and it is main's, not this branch's. I found it because it killed a test I had here — bills a replay when the cache write failed after the call — and you had already deleted that test in the merge, which was right: its route is gone.

When a cache write fails for a reason that is not cancellation, diffAndStore rolls back the run-state write that preceded it, so the answer the run already paid for is discarded and the retry pays again.

store.ts:1072:

if (stateWasPublished && (signal?.aborted || afterStore || atomicWriteWasPublished(err))) {

afterStore is truthy whenever a callback was passed at all, so any rejection from it triggers the rollback — not only a cancelled one. The comment above it says "so readers never reuse a cancelled result", which is the signal?.aborted case.

Measured: one gpt-4o call through a stub OpenClaw provider, retry: {max: 2, delay_ms: 1}, LOBSTER_RUN_STATE_KEY set, and a cache whose entry commit fails (fsp.rename rejects EACCES for paths under the cache dir, lock files excluded, so only the entry's own commit fails):

head provider calls run
a91734f (before #119) 1 ok — the retry replayed the stored answer
2dbf698 2 fails, after paying twice

With more retries it is more calls: the run pays once per attempt and keeps nothing. ENOTDIR no longer reaches this path after your fix, but a cache that is present and unwritable — a full disk, a read-only mount, a permissions change mid-run — still does.

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 signal?.aborted || atomicWriteWasPublished(err) if you would rather have the change than the report.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/workflows/file.ts
result: WorkflowStepResult;
};
parallelBranchResults = { [winner.branchId]: winner.result };
branchLedgers.get(winner.branchId)?.release();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: cached llm.invoke results are billed again, so cost_limit stops runs that never spent the budget

2 participants