Skip to content

fix: Persist per-node workflow token usage - #2347

Merged
Wirasm merged 4 commits into
devfrom
archon/task-archon-fix-github-issue-experimental-1785419683548
Jul 31, 2026
Merged

fix: Persist per-node workflow token usage#2347
Wirasm merged 4 commits into
devfrom
archon/task-archon-fix-github-issue-experimental-1785419683548

Conversation

@Wirasm

@Wirasm Wirasm commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Problem: successful workflow node completion events omitted token usage even though providers supplied it and the executor already collected it.
  • Why it matters: API, CLI, and remote-console consumers could not inspect per-node usage without reading local JSONL logs.
  • What changed: direct AI, loop, and loop-group completion events now persist their existing token values; regression tests cover all three paths.
  • What did not change (scope boundary): provider contracts, run-level aggregation, event schema/storage, APIs, and database migrations are unchanged.

UX Journey

Before

Workflow operator          Provider              Workflow executor       Event consumers
───────────────          ────────              ─────────────────       ───────────────
runs workflow ─────────▶ returns result
                           + token usage ────▶ captures tokens
                                                  writes completion
                                                  event (without tokens) ──▶ cannot view node usage

After

Workflow operator          Provider              Workflow executor       Event consumers
───────────────          ────────              ─────────────────       ───────────────
runs workflow ─────────▶ returns result
                           + token usage ────▶ captures tokens
                                                  writes completion
                                                  event [with tokens] ─────▶ [views node usage]

Architecture Diagram

Before

Provider result tokens ──▶ DAG executor ──▶ JSONL logger
                                 │
                                 └───────▶ workflow_events (node_completed; tokens omitted) ──▶ API / CLI / console

After

Provider result tokens ──▶ [~ DAG executor] ──▶ JSONL logger
                                 │
                                 └════════▶ workflow_events (node_completed; [tokens persisted]) ──▶ API / CLI / console

Connection inventory (list every module-to-module edge, mark changes):

From To Status Notes
Provider result chunks DAG executor unchanged Providers already normalize optional token usage.
DAG executor JSONL logger unchanged Direct-node logging already includes tokens.
DAG executor workflow event store modified Successful direct, loop, and loop-group completion payloads now include available tokens.
Workflow event store API / CLI / console consumers unchanged Existing generic event data is returned without schema or storage changes.

Label Snapshot

  • Risk: risk: low
  • Size: size: S
  • Scope: workflows
  • Module: workflows:executor

Change Metadata

  • Change type: bug
  • Primary scope: workflows

Linked Issue

Validation Evidence (required)

Commands and result summary:

bun run type-check
bun run lint
bun run format:check
bun run test
bun run validate
  • Evidence provided (test/log/trace/screenshot): focused DAG executor tests passed (434 passed, 0 failed); full validation in a clean temporary snapshot containing only the two issue files passed. Recorded validation also reports type check, lint, format check, build, and 6,401 tests passing.
  • If any command is intentionally skipped, explain why: none. The live worktree validation was blocked only by unrelated pre-existing .archon/ edits that make bundled defaults stale; the same full gate passed in the clean snapshot with this fix applied.

Security Impact (required)

  • New permissions/capabilities? (No)
  • New external network calls? (No)
  • Secrets/tokens handling changed? (No; this persists existing aggregate usage counts, not credentials or model prompts.)
  • File system access scope changed? (No)
  • If any Yes, describe risk and mitigation: Not applicable.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Database migration needed? (No)
  • If yes, exact upgrade steps: Not applicable.

Human Verification (required)

What was personally validated beyond CI:

  • Verified scenarios: direct AI node, loop wrapper, and loop-group completion events retain their reported or aggregated token objects.
  • Edge cases checked: absent token usage remains absent rather than being converted to a synthetic zero or undefined event field.
  • What was not verified: live remote-console rendering was not manually exercised; it reads the unchanged event-data contract.

Side Effects / Blast Radius (required)

  • Affected subsystems/workflows: workflow DAG execution and consumers of persisted node_completed event data.
  • Potential unintended effects: event records carrying reported usage gain a small optional JSON object.
  • Guardrails/monitoring for early detection: targeted regression tests assert exact direct and aggregate token payloads; optional spreading preserves no-usage behavior.

Rollback Plan (required)

  • Fast rollback command/path: revert commit aa11bf1a from dev if event payload expansion causes an integration issue.
  • Feature flags or config toggles (if any): None.
  • Observable failure symptoms: consumers reject an added optional tokens property or display unexpected node usage values.

Risks and Mitigations

  • Risk: a provider reports no usage or an incomplete usage object.
    • Mitigation: persist only non-undefined values and preserve the provider-normalized object without recalculation.

Workflow node token usage was only written to local JSONL logs, leaving remote event consumers without provider-neutral per-node usage data.

Changes:
- Persist direct AI-node token usage in node_completed events
- Persist aggregated loop and loop-group token usage
- Add regression coverage for all successful AI-derived completion paths

Fixes #2333
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4677450b-0201-41b7-b416-2e2a5bb150b0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Wirasm

Wirasm commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated Review: PR #2347

Date: 2026-07-30T17:24:44+03:00
Agents: code-review, error-handling, test-coverage, comment-quality, docs-impact
Total Findings: 1 from 2 available agent artifacts


Executive Summary

This is a small, well-scoped change that persists already-captured token usage on successful direct AI, loop, and loop_group completion events. The code review found no correctness, security, type-safety, or maintainability concerns, and focused, type, lint, format, and build validation all passed. The test review confirms strong positive-path coverage, including loop-group aggregation totals. It recommends one additional boundary test: assert that the tokens key is omitted—not present as undefined or a synthetic zero value—when a provider reports no usage. Error-handling, comment-quality, and docs-impact artifacts were not available when this synthesis was produced, so their review results are explicitly not represented.

Overall Verdict: NEEDS_DISCUSSION

Auto-fix Candidates: 0 CRITICAL + HIGH issues can be auto-fixed
Manual Review Needed: 1 MEDIUM issue requires a decision; 3 specialist artifacts are missing


Statistics

Agent CRITICAL HIGH MEDIUM LOW Total
Code Review 0 0 0 0 0
Error Handling N/A N/A N/A N/A Unavailable
Test Coverage 0 0 1 0 1
Comment Quality N/A N/A N/A N/A Unavailable
Docs Impact N/A N/A N/A N/A Unavailable
Total (available artifacts) 0 0 1 0 1

CRITICAL Issues (Must Fix)

No critical issues were reported by the available artifacts.


HIGH Issues (Should Fix)

No high-severity issues were reported by the available artifacts.


MEDIUM Issues (Options for User)

Issue 1: Optional token-key omission is not regression-tested

Source Agent: test-coverage-agent
Location: packages/workflows/src/dag-executor.ts:2253,3509,4655 and packages/workflows/src/dag-executor.test.ts

Problem:

The new tests verify token values when usage is present, but do not assert that node_completed.data has no own tokens property when a provider reports no usage. The three completion writers are independent, so a later change could emit tokens: undefined or a synthetic zero object without these tests catching it.

Options:

Option Approach Effort Risk if Skipped
Fix Now Add no-usage completion assertions for direct AI, loop, and loop_group, checking that data does not own tokens. MED Consumers could lose the distinction between unreported and zero usage.
Create Issue Defer the boundary cases to a focused test-coverage PR. LOW Same risk remains until addressed.
Skip Accept the existing positive-path coverage. NONE Future refactors could alter the optional-field contract unnoticed.

Recommendation: Fix now. Three small executor-level assertions match the PR's three-path scope and protect the persisted event contract without adding production complexity.


LOW Issues (For Consideration)

No low-severity issues were reported by the available artifacts.


Positive Observations

  • The production change is localized to the three intended successful AI-derived completion paths.
  • It reuses existing token aggregation and the established conditional-property-spread convention.
  • Tests inspect persisted node_completed event data, which directly exercises the regression boundary.
  • Direct AI, loop, and loop-group aggregate values are covered, including exact loop-group totals.
  • Focused executor tests, type-checking, linting, format checking, and build validation passed.

Suggested Follow-up Issues

Issue Title Priority Related Finding
Add no-usage token-omission regression coverage for workflow completion events P2 MEDIUM issue #1

Next Steps

  1. Add or explicitly defer the three no-usage token-omission assertions.
  2. Produce the missing error-handling, comment-quality, and docs-impact artifacts before treating this as a complete five-agent review.
  3. Re-synthesize after those artifacts are available.

Agent Artifacts

Agent Artifact Findings
Code Review code-review-findings.md 0
Error Handling error-handling-findings.md Unavailable
Test Coverage test-coverage-findings.md 1
Comment Quality comment-quality-findings.md Unavailable
Docs Impact docs-impact-findings.md Unavailable

Metadata

  • Synthesized: 2026-07-30T17:24:44+03:00
  • Artifact: /Users/rasmus/.archon/workspaces/coleam00/Archon/artifacts/runs/d4e8cf9d3a97dd37bac9351adda25a6a/review/consolidated-review.md
  • Completeness: 2 of 5 requested agent artifacts were present.

Fixed:\n- cover omission of unreported token usage in completion events\n\nTests added:\n- direct AI and loop_group no-usage completion coverage\n- loop no-usage assertion\n\nSkipped:\n- none
@Wirasm

Wirasm commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Fix Report: PR #2347

Date: 2026-07-30T14:27:59Z
Status: COMPLETE
Branch: archon/task-archon-fix-github-issue-experimental-1785419683548
Commit: e07dfa8
Philosophy: Aggressive fix — lean towards fixing everything


Summary

Review artifacts reported one medium-severity coverage gap: successful completion events without provider usage were not asserted to omit tokens. Added direct AI, loop, and loop_group regression coverage for that public event-shape contract. No findings were skipped or blocked; no documentation changes were needed.


Fixes Applied

Severity Finding Location What Was Done
MEDIUM Optional token-key omission is not regression-tested packages/workflows/src/dag-executor.test.ts:4767 Added a direct-AI no-usage case that asserts node_completed.data has no tokens property.
MEDIUM Optional token-key omission is not regression-tested packages/workflows/src/dag-executor.test.ts:5239 Extended the existing no-usage loop case to assert tokens is omitted.
MEDIUM Optional token-key omission is not regression-tested packages/workflows/src/dag-executor.test.ts:14526 Added a loop-group no-usage case that asserts node_completed.data has no tokens property.

Tests Added

File Test Cases
packages/workflows/src/dag-executor.test.ts Direct AI and loop-group no-usage completion events omit tokens; existing loop no-usage scenario now asserts the same.

Docs Updated

(none)


Skipped Findings

(none)


Blocked (Could Not Fix)

(none)


Suggested Follow-up Issues

(none)


Validation

Check Status
Type check
Lint
Focused executor tests ✅ 436 passed
Workspace tests

@Wirasm

Wirasm commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

pr: 2347
title: "fix: Persist per-node workflow token usage"
author: "Wirasm"
reviewed: 2026-07-30
recommendation: request-changes

PR Review: #2347 — Persist per-node workflow token usage

Branch: archon/task-archon-fix-github-issue-experimental-1785419683548dev (draft)
Files: 2 (+107/−2) — 3 production lines in packages/workflows/src/dag-executor.ts, rest tests
Closes: #2333


Verdict

NEEDS FIXES — the change is correct as far as it goes, and the aggregation semantics are sound. But it covers 3 of the 4 AI-derived node_completed paths, and on the loop_group path it makes the persisted event stream double-count, which undercuts the issue's own motivating use case.


Execution results (facts, separate from reasoning below)

Isolated worktree at /Users/rasmus/Projects/cole/Archon-2347review off pr-2347-review (e07dfa8).

Item 3 — do the tests fail against origin/dev? REPRODUCED

Reverted only packages/workflows/src/dag-executor.ts to origin/dev (git diff --stat confirmed exactly 1 file changed, 3 deletions(-)), kept all new tests:

Test Reverted With fix
stores node_output in node_completed event data for AI nodes FAIL PASS
records requested model/tier on node_started and the resolved model on node_completed (#2314) FAIL PASS
COST: accumulates token usage across loop_group iterations FAIL PASS
omits tokens from a direct AI node_completed event when the provider reports no usage pass pass
omits tokens from a loop_group node_completed event when providers report no usage pass pass

Each of the three production sites has a test that genuinely fails without it. The two omits tokens… tests pass either way — correctly so (they are absence-contract guards), but they prove nothing about the fix itself and should not be counted as coverage of it.

Full file restored: 436 pass / 0 fail.

Validation

Check Result
bun run type-check PASS (all 13 packages)
bun run lint PASS (clean)
bun --filter @archon/workflows test PASS (exit 0)
bun test packages/workflows/src/dag-executor.test.ts 436/436

Empirical

~/.archon/archon.db (read-only): 548 node_completed events, 0 carrying tokens. The gap is real.


Item 1 — aggregation semantics. SETTLED: the sum is correct, no double-count.

executeLoopNode already carries the #2083 fix for tokens:

  • dag-executor.ts:4199-4210 — within an iteration, iterationTokens uses overwrite semantics (iterationTokens = {…}), with an explicit comment that a later result chunk from a background-task wait carries session-cumulative values.
  • dag-executor.ts:4070-4085foldIterationUsage() folds the last-seen per-iteration value into loopTotalTokens exactly once, latched by iterationUsageFolded.

So a second result chunk in one iteration overwrites rather than adds. The hazard you flagged was already handled, and it carries a Number.isFinite guard the direct path does not (see S2).

executeLoopGroupNode (dag-executor.ts:3327-3331) sums iterCtx.totalTokensIn/Out with a fresh iterCtx per iteration — a genuine per-iteration sum.

Summing tokens while resolvedModel stays last-seen-wins is deliberate and right.

Item 2 — loop_group bodies route through executeNodeInternal, and 300/30 is a real sum.

The fixture yields distinct values per iteration ({100,10} then {200,20}) and pins expect(calls).toBe(2), so 300 is provably 100+200, not a fixture coincidence. Body nodes reach iterCtx.totalTokensIn via NodeOutput.tokens at dag-executor.ts:6220-6221.


Issues Found

Important

I1 — loop_group double-counts in the persisted event stream.
packages/workflows/src/dag-executor.ts:3509

I instrumented the loop_group test and dumped every node_completed the run persists:

paid.work → {"input":100,"output":10}   ← iteration 1 body node
paid.work → {"input":200,"output":20}   ← iteration 2 body node
paid      → {"input":300,"output":30}   ← group aggregate (new)

Why: a consumer summing node_completed.tokens over a run gets {600,60} for {300,30} of real usage — exactly 2×. Leaf rows and the aggregate row use the same field name with no discriminator; the only signal is that step_name contains a ., which is undocumented and fragile. This is precisely the consumer #2333 exists to serve ("benchmark query", "per-run cost/efficiency reporting"). Note loop does not have this problem — its per-iteration events are loop_iteration_completed (:4618) and carry no tokens; only loop_group emits both levels. Run-level totals stay correct (aggregated once at :6215); this is an event-stream-only defect.

Fix: either drop tokens from the group-level event (bodies already report it, so the sum is recoverable), or mark the aggregate — e.g. tokens_rollup: true or type: 'loop_group' — so consumers can filter. Whichever you pick, say it in the event-shape docs.


I2 — the workflow: sub-run node is a 4th AI-derived path, and it was missed.
packages/workflows/src/dag-executor.ts:5147

data: {
  node_output: output,
  type: 'workflow',
  child_run_id: outcome.childRunId,
  ...(outcome.costUsd !== undefined ? { cost_usd: outcome.costUsd } : {}),
  //  ← outcome.tokens dropped here
},

…while the same function, 29 lines later at :5176, already does:

...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),

ChildWorkflowOutcome.tokens is declared at :325 and populated by childOutcomeFromRun (:364-388) from the child's total_tokens_in/total_tokens_out metadata. The value is live and in scope.

Why: this is the identical defect #2333 describes — captured, threaded to the return value, dropped from the persisted event. It is also backwards relative to the issue's own thesis: the sub-run node propagates cost (which Codex cannot report, so it is not comparable) and drops tokens (the one axis that is). A workflow using workflow: nodes gets a queryable event stream with cost but no tokens on exactly the nodes that did the work.

Fix: one-line spread, mirroring cost_usd, plus a test.


I3 — finalizeLoopFromSignal emits a token-less node_completed.
packages/workflows/src/dag-executor.ts:3049

data: { duration_ms: 0, node_output: finalizeOutput },

This is the #2074 path: on an interactive loop gate whose iteration already emitted the completion signal, approving with no text finalizes the node from the already-computed output without a new iteration. The function signature (:3021-3030) takes no usage parameters at all, so tokens are unreachable from inside it — but both call sites (:3153, :3894) have loopTotalTokens in scope.

Why: an interactive loop that finalizes at the gate persists duration_ms: 0 and no tokens, despite having consumed them across its iterations. Silent — the operator sees a node that apparently cost nothing. Pre-existing (cost_usd is missing too, so this is not a regression), but it sits squarely inside this PR's stated scope of "loop completion events", and the run reports a number that will be believed.

Fix: thread the loop totals into the finalize helper and spread them like the other sites; or, if deliberate, comment why this path reports no usage.


Suggestions

S1 — the persisted tokens shape is not uniform across the three patched sites.
dag-executor.ts:2253 vs :3509 / :4655

The direct-node path persists the raw provider object. TokenUsage (packages/providers/src/types.ts:167-172) is {input, output, total?, cost?}, and real logs confirm the 4-field form is common:

{"input":2592,"output":170,"total":2762,"cost":0.0009816}

The loop paths instead construct {input, output} only, dropping total/cost. That matters because total is not input + output: Pi's usageToTokens (community/pi/event-bridge.ts:105-112) sets total from usage.totalTokens, which includes cache tokens, and OpenCode does the same (its own test maps {input:11, output:7, reasoning:3, cache:1}total: 21). I found a real log line where total is 2759 against input+output of 172.

So one field name now carries two different accounting bases depending on node type, and tokens.cost duplicates the sibling cost_usd. On payload size (your item 6) the PR is fine — TokenUsage is capped at 4 numeric fields and the richer cache/reasoning breakdown is already discarded at the provider boundary, so nothing unbounded rides along. The problem is consistency, not size.

Fix: normalise at all three sites — either persist {input, output} everywhere, or carry total/cost everywhere.

S2 — the new direct-node site bypasses the repo's NaN guard.
dag-executor.ts:1644:2253

Capture is if (msg.tokens) nodeTokens = msg.tokens; — no finiteness check. The repo guards this value twice elsewhere, deliberately:

  • :6215"Token values come from providers (incl. community ones) — guard so a NaN can't silently poison the totals"
  • :4202-4210 — the loop node's per-iteration capture, which references that rationale

Before this PR, nodeTokens only reached the JSONL log and the guarded run-level aggregator. It now goes straight to the store, unguarded. JSON.stringify(NaN) is null, so a misbehaving provider yields a persisted tokens: {input: null, output: null}. The loop paths are protected; the direct path is not.

Fix: reuse the same Number.isFinite check at capture, with the existing usage_tokens_non_finite_ignored warn.

S3 — zero-usage asymmetry (your item 4).
Absence is preserved when the provider omits msg.tokens — I verified both new tests and confirmed no path emits tokens: undefined. But a provider that reports an explicit {input: 0, output: 0} is truthy at :1644, so the direct node persists {input:0,output:0}, while loop_group's > 0 guard (:3327) skips it and omits the field. The same zero report is therefore recorded as "zero" by one node type and "unreported" by another. Not the fabricated-number bug you were worried about, but the same family.


Strengths

  • Finding and fixing the loop_group path unprompted — the exact gap fix(providers): record resolved model metadata #2337 was sent back for.
  • Reuses the established conditional-spread convention; zero new production complexity.
  • Tests assert against deps.store.createWorkflowEvent (the persisted row), not the in-process emitter — which is what actually matters, and confirms your item 5.
  • The loop_group fixture uses distinct per-iteration values, so it tests summation rather than a repeated constant. That is better than the average test in this file.
  • Absence-contract tests were added on review feedback rather than argued away.

Notes on the issue spec

Beyond the loop-path omission already identified, #2333 was incomplete in three ways:

  1. It missed the workflow: sub-run node (I2) — an AI-derived completion event with tokens already in scope and its sibling cost_usd already handled.
  2. It missed finalizeLoopFromSignal (I3).
  3. It proposed the one-line spread without noticing that adding tokens to both body and aggregate rows makes the loop_group event stream non-summable (I1) — which undercuts the benchmarking motivation the issue is built on.

One factual correction: the issue lists "Roll-up on the run" as still to be decided. It already exists — total_tokens_in / total_tokens_out are written to run metadata at dag-executor.ts:7300-7301, and childOutcomeFromRun reads them back. That part of the spec is asking for something already built.


Recommendation

NEEDS FIXES. I2 is a one-line fix with the value in scope and should land with this PR — leaving it means shipping the fix for #2333 while an equivalent instance survives in the same file. I1 needs a decision (drop the aggregate, or mark it) before consumers start summing. I3 and S2 are small and worth folding in. S1/S3 are consistency cleanups that can be deferred if you prefer to keep the diff tight, but they should be a follow-up issue rather than nothing.

Reviewed by Claude

Review findings on the initial persistence change.

B1 — loop_group double-counted the persisted event stream. A loop_group's
body nodes write their own namespaced node_completed rows, so persisting the
group total under the same `tokens` field name meant a consumer summing
node_completed rows got 600/60 for 300/30 of real usage, with nothing in the
row marking it as an aggregate. Chose omission over a discriminator: the
leaves are authoritative (per-provider, so usable for the cross-provider
comparison the issue exists to enable) and already summable by step-name
prefix, and omission means a naive consumer gets the right total without
having to know about a discriminator field. The return value still carries
the total — that is the run-level roll-up, which counts each group once.
`cost_usd` has the same double-count shape and predates this; left as-is and
noted rather than silently changed under a token fix.

B2 — the `workflow:` sub-run node persisted the child's rolled-up cost but
not its tokens, dropping the one axis every provider reports. Added.

B3 — finalizeLoopFromSignal (bare approve at a signalled gate) wrote a
node_completed with no usage for iterations that really ran. The gate now
carries the pausing invocation's usage across the pause in the approval
context, matching what the normal re-run completion path reports. Cost and
resolved model are lost across the same gate; those belong to the single
"preserve terminal provider stats across a gate" change tracked by #2345.

Also normalizes the direct-node capture to `{input, output}` with a
finite-value guard, mirroring the loop-node capture: `total` is
provider-defined and is not input + output (Pi folds cache tokens into it),
`cost` duplicates cost_usd, and a NaN would persist as a null a consumer
would believe.
@aberkowCollibra

Copy link
Copy Markdown

This would be extremely useful and is a blocker from my team using this tool right now. We want to track token usage per node.

@Wirasm

Wirasm commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Second-pass review — scoped to the fix commits (e07dfa8e, 519d2479)

Verdict: NEEDS FIXES — one blocking defect.

The first review's finding (B1) was real and the chosen remedy is right. But the same defect now exists on a different path: B3 reintroduces the loop_group double-count at the finalize-on-approve gate.


C1 (blocking) — finalizeLoopFromSignal re-reports loop_group body usage

finalizeLoopFromSignal is shared by executeLoopNode and executeLoopGroupNode. B3 added tokens to the node_completed row it writes. That is correct for a plain loop (its per-iteration rows carry no tokens, so this is the only row). It is not correct for a loop_group: the body nodes already wrote their own namespaced <groupId>.<nodeId> rows carrying those tokens before the pause, and those rows survive it. The finalize row then re-reports the same usage under the same field name — exactly the shape the comment at dag-executor.ts:3555 exists to prevent.

Measured, not argued. One shared event store across pause → bare-approve → resume, so the persisted stream matches a real DB. Provider reports {input: 100, output: 10} on the single body iteration; interactive: true, no signal_completes, so iteration 1 signals and gates; resume with no feedback finalizes.

gate approval context signaledTokens = {"input":100,"output":10}

phase 1 (paused at gate) — persisted node_completed rows
  step_name=refine.work    tokens={"input":100,"output":10}
  NAIVE SUM: {"input":100,"output":10}          <- correct

phase 2 (after bare-approve finalize) — persisted node_completed rows
  step_name=refine.work    tokens={"input":100,"output":10}
  step_name=refine         tokens={"input":100,"output":10}   <- the finalize row
  NAIVE SUM: {"input":200,"output":20}          <- 2x, for 100/10 of real usage

For contrast, the path the fix did address is now correct:

loop_group natural completion (no gate)
  step_name=grp.work       tokens={"input":100,"output":10}
  step_name=grp            tokens=null
  NAIVE SUM: {"input":100,"output":10}          <- correct

Fix: drop the token argument at the loop_group call site only — dag-executor.ts:3198-3208 should not pass readSignaledTokens(loopGateMeta.signaledTokens). Keep it at executeLoopNode's call site (:3957-3966), which is correct. signaledTokens at the loop_group gate pause (:3655) then has no consumer and should go with it; the ApprovalContext field and the pauseWorkflowRun explicit-null reset stay, since the plain loop needs them.

Worth extending the :3555 comment to say the rule binds every row written for a loop_group node, not just that one — a per-call-site invariant is what let this through.

Test gap that allowed it: the naive-sum assertion added to COST: accumulates token usage across loop_group iterations is the strongest test in the fix and genuinely fails against dev — but it only covers natural completion. The missing case is the same assertion on the loop_group finalize path.


I1 — the signaledTokens doc comment claims a recoverability that does not exist

packages/workflows/src/schemas/workflow-run.ts:

a loop that gated more than once attributes each invocation's usage to that invocation, and earlier ones are only visible in the run-level totals

Measured: they are not visible anywhere. A pausing invocation never reaches completeWorkflowRunskipIfStatusChanged bails on a non-running status, and total_tokens_in/total_tokens_out are only written there (:7373). So for a loop that gates twice:

invocation 1: iteration 1 consumes 40/4, no signal  -> gate. No node_completed row.
                                                       No run-row totals (run is paused).
invocation 2: iteration 2 consumes 60/6, signals    -> completes.
  persisted node_completed:  step_name=refine  tokens={"input":60,"output":6}
  run row total_tokens_*:    60/6

Node truly consumed 100/10. The 40/4 is in neither the node row nor the run row.

The under-report itself is pre-existing (dev persisted nothing at all here, so this is not a regression) and belongs with #2345. But in a PR whose purpose is per-node usage visibility for cross-provider comparison, a comment asserting the loss is covered will mislead whoever next reads these numbers. Correct the comment or move the claim into #2345.


Verified correct

  • Run-level roll-up is unaffected by the aggregate omission. It sums node return values at the single aggregation point :6288, not persisted rows, and executeLoopGroupNode still returns tokens (:3591, :3661). Body results land in the scoped iteration ctx and never reach the run ctx. Confirmed by the natural-completion dump above.
  • Plain loop still emits tokens exactly once — verified across a real gate pause + finalize: one row, {input: 40, output: 4}, no second emission.
  • S1 (shape consistency) is fixed. The direct-node capture is normalized to {input, output} with a finite guard (:1641-1660), so all four persist sites now emit the same two-field shape. A consumer can no longer be handed a provider-defined total it cannot interpret. This was the right call — the alternative (persisting total) would have made the benchmarking use case unusable across providers.
  • B2 (workflow: sub-run) is correct and cannot double count — the child's per-node rows are filed under a different workflow_run_id. Its test genuinely fails against dev.

Test-quality note

Reverting only dag-executor.ts to origin/dev and keeping the tests: 6 fail in dag-executor.test.ts, 1 in subrun.test.ts. But 4 of the 5 newly-added tests pass either wayomits tokens from a direct AI node_completed…, drops non-finite provider token counts…, finalize omits tokens when the gate persisted none…, omits tokens from loop_group body node_completed events…. They are correct as absence guards but prove nothing about the change; the same property the first review flagged. Only persists only {input, output}… actually fails.

Validation

Check Result
bun run type-check PASS
bun run lint PASS (0 warnings)
bun run format:check PASS
bun --filter @archon/workflows test PASS, 0 failures
packages/core/src/db/workflows.test.ts 82 pass (in isolation)
packages/core/src/workflows/store-adapter.test.ts 13 pass (in isolation)

finalizeLoopFromSignal is shared by executeLoopNode and executeLoopGroupNode.
Passing it the gate's signaledTokens is right for a plain loop -- its
per-iteration rows carry no tokens, so the finalize row is the only record of
the usage -- but wrong for a loop_group, whose body nodes persisted their own
`<groupId>.<nodeId>` rows (with tokens) BEFORE the pause. Those rows survive the
pause, so the finalize row repeated usage already in the same event stream: a
consumer summing `data.tokens` read 200/20 for 100/10 of real usage.

This is the same double-count the natural-completion group row already avoids by
omitting `tokens`; the gate path had simply not been covered.

- loop_group finalize call site no longer passes finalizeTokens
- loop_group gate pause no longer writes signaledTokens (it had no consumer left)
- the schema field and the pauseWorkflowRun explicit-null reset stay: the plain
  loop still writes the field, and the reset still clears a stale value from a
  prior gate in the same run

Tests: a loop_group gate -> bare approve -> resume across ONE shared event store,
asserting the naive sum over persisted node_completed rows equals the true usage.
Per-test isolation is what hid this: the pre-pause body rows have to survive into
the resume for the double-count to appear at all. Plus a guard that the
loop_group gate context carries no signaledTokens.

Also corrects a measurably false claim in the signaledTokens doc comment: earlier
invocations' usage is NOT visible in the run-level totals. A pausing invocation
never reaches completeWorkflowRun (status is `paused`, so the pre-complete check
bails) and `total_tokens_*` are written only there -- measured on a twice-gated
loop, 40/4 then 60/6, the node row and the run row both report only 60/6. That
under-report is pre-existing and belongs to #2345; only the comment changed.
@Wirasm
Wirasm marked this pull request as ready for review July 31, 2026 08:07
@Wirasm
Wirasm merged commit 9251dac into dev Jul 31, 2026
4 checks passed
@Wirasm
Wirasm deleted the archon/task-archon-fix-github-issue-experimental-1785419683548 branch July 31, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(workflows): per-node token usage is written only to the JSONL log, never to workflow_events

2 participants