Skip to content

fix: reasoning-model flakiness, thinking-mode support, and high-volume-ci tuning#46

Merged
TGPSKI merged 22 commits into
mainfrom
fix/high-volume-ci-load-test-fixes
Jul 4, 2026
Merged

fix: reasoning-model flakiness, thinking-mode support, and high-volume-ci tuning#46
TGPSKI merged 22 commits into
mainfrom
fix/high-volume-ci-load-test-fixes

Conversation

@TGPSKI

@TGPSKI TGPSKI commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

A grab-bag of fixes found while load-testing examples/11-high-volume-ci
end-to-end against a real local vLLM endpoint (Qwen3, --tool-call-parser qwen3_xml --reasoning-parser qwen3, concurrency 8+, bursts of 40-100
webhooks). Split out from #45, which covers a closely related but distinct
set of fan-in/collect fixes in internal/curing — this PR is based on top
of that branch.

Fixes

  • HTTP client timeout races the context deadline
    (internal/session/http_client.go): http.Client{Timeout: ...} fired
    independently of the run's context deadline, producing spurious "context
    deadline exceeded" errors well before the real timeout. The context
    deadline is now the single timeout source.

  • Reasoning-model flakiness in the tool-call loop
    (internal/runner/runner.go): confirmed via direct replay against the
    LLM endpoint outside leather that the model would occasionally re-issue
    an already-succeeded tool call verbatim (looping until max tool rounds),
    and separately would sometimes stop naturally with a completely empty
    final answer (finish_reason: "stop", not truncation). Not a request-
    construction bug — an explicit "don't do this" prompt instruction had
    zero effect; ~25% of isolated replays reproduced the loop. Added a
    duplicate-tool-call guard (an identical call that already succeeded this
    run is not re-executed) and extended the existing length-truncation
    self-healing retry to also cover the empty-stop case.

  • thinking: agent frontmatter field
    (internal/agent/frontmatter.go, internal/agent/agent.go,
    internal/model/model.go, internal/runner/runner.go): new per-agent
    override (Agent.DisableThinking) sending
    chat_template_kwargs.enable_thinking=false. Disabling the hidden
    reasoning trace turned out to be the most effective fix for both
    flakiness modes above, and measured a 5.2x speedup on a 40-webhook
    burst load test (323s -> 62s) once combined with right-sizing
    completion_reserve. Zero value (false) leaves model default behavior
    untouched everywhere else.

  • Answer text emitted alongside tool calls was dropped from the final
    answer
    (internal/runner/runner.go): a third member of the same
    flakiness family, found in a follow-up 40-webhook burst — the model
    sometimes emits the head of its final answer (e.g. the PR_NUMBER/REPO/SHA
    header) in the same completion as a tool call, then continues from
    where it stopped after seeing the tool result. The runner recorded only
    the last round's content, silently head-truncating 6/40 pr-metadata
    artifacts and cascading into blank or literal-placeholder
    (PR_NUMBER: <number>) decision reports and garbage post_pr_comment
    calls. Fragments emitted in non-hide tool-call rounds are now banked and
    spliced ahead of the final round's text, and the bare empty-stop retry is
    skipped when banked fragments already carry the answer.

  • 16-bit ID suffixes collide under burst load, cross-wiring fan-in
    groups
    (internal/ids/ids.go, internal/hide/store.go): root cause of
    a 99/100 completion on a 100-webhook burst. ids.TimestampHex drew its
    uniqueness suffix from mathrand.Int31n(0x10000) — 65,536 values per
    (prefix, minute) bucket, ~1% birthday-collision odds at ~40 IDs/minute.
    Two concurrent pr-context legs drew the same hide ID; Put's
    os.MkdirAll silently merged them; one PR's decision group collected the
    shared hide, succeeded, and deleted it; the other PR's group found its
    leg's hide missing 6 seconds later and DLQ'd after retries (the fix: curing collect fan-in hangs forever and leaks state when a leg permanently DLQs #45
    requeueOrDLQGroup machinery surfaced the loss exactly as designed —
    with the opposite race order this would have been a silently wrong
    decision built from another PR's analysis). Suffix widened to 32 bits,
    and hide.Store.Put now creates the entry directory exclusively
    (os.Mkdir), regenerating the ID on collision instead of overwriting an
    existing hide.

  • shell-mcp per-argument patterns validation (cmd/shell-mcp/main.go):
    new optional per-tool patterns map in shell-tools.json (argument key →
    RE2 regexp), validated before the command runs and advertised in the
    tool's inputSchema. A missing argument validates as the empty string, so
    anchored patterns also reject absent values — a flaky model passing blanks
    or literal prompt-template placeholders like <number> now fails loudly
    at the MCP boundary instead of executing with garbage.

  • examples/11-high-volume-ci fixes and tuning:

    • PR-identifier guards end to end: shell-tools.json pattern-constrains
      pr_number / repo on all four GitHub tools; decision.agent.md
      emits Decision: ERROR instead of guessing or echoing template
      placeholders when the pr-metadata block lacks a numeric PR_NUMBER;
      pr-comments.agent.md makes no tool calls on a malformed report and
      reports the skip.
    • decision.agent.md: fixed a positional fan-in bug — the prompt
      assumed the 3 parallel analysis agents always arrive in submission
      order ("copy from ANALYSIS 1"), but pr-context (zero tool calls)
      often won that race, leaving nothing to copy and producing
      PR_NUMBER: N/A. Now references the block by its (from: pr-metadata) tag instead of position.
    • All 5 agents: thinking: false + completion_reserve: 768 (down
      from the reasoning-model default of 8192).
    • config.yaml / shell-tools.json / tannery.yaml: removed stale
      llm_timeout, raised max_concurrent_jobs, tool timeouts, and
      comments-in concurrency to match real burst-load latency.
    • run-demo.sh: fixed the completion-wait to track pr-comments (the
      actual last stage) instead of decision, and moved the queue-drain +
      server-shutdown step before the results summary — previously the
      summary printed while the server's own --pretty live trace
      (unredirected, sharing the terminal) was still streaming, making it
      look like output kept coming after the script said it was done.
    • README: documented all of the above, including the before/after
      performance table.
  • Full-system load-test profiler (scripts/profile-run.sh,
    scripts/profile-summary.py): wraps any command with samplers for host
    CPU/mem/disk, PSI pressure, per-process attribution, GPU telemetry, and
    vLLM /metrics, plus an avg/peak summarizer. Used to produce the
    100-webhook profile now documented in the example README ("Measured at
    scale"): 100/100 through every stage in 190s — 500 LLM jobs / 965k tokens
    orchestrated for 6.5% of one host's CPU, zero PSI stalls, GPU-bound end
    to end at 4.6k prompt tok/s sustained.

  • CHANGELOG: backfilled Unreleased entries for everything above plus the
    four curing-worker concurrency fixes that shipped via fix: curing collect fan-in hangs forever and leaks state when a leg permanently DLQs #45.

Test plan

  • go build ./...
  • go vet ./...
  • go test ./...
  • New tests: TestRunner_DuplicateToolCallSkipsReExecution,
    TestRun_SelfHealingRetry_OnEmptyStopCompletion,
    TestRun_EmptyStopRetry_IsSingleShot,
    TestRun_DisableThinkingMergesChatTemplateKwargs,
    TestParseFrontMatter_ThinkingFalse,
    TestParseFrontMatter_ThinkingDefaultsToEnabled,
    TestRun_SplitAnswerAcrossToolRounds_IsReassembled,
    TestRun_SplitAnswer_EmptyFinalStopSkipsBareRetry,
    TestExecutePatternValidation, TestToolListAdvertisesPatterns,
    TestExampleShellToolsJSON_PatternsCompileAndAreDeclared,
    TestStore_Put_RegeneratesIDOnCollision,
    TestStore_Put_PersistentCollisionErrors
  • Live-verified against a real vLLM endpoint: 40-webhook burst load
    test, 0 max-tool-rounds failures, 0 DLQ events, 0 errors (vs.
    intermittent failures on the pre-fix baseline), 323s -> 62s; a
    follow-up burst surfaced the split-answer head-truncation (6/40
    malformed reports), root-caused from .state/runs/*.jsonl token
    accounting and fixed above

@TGPSKI
TGPSKI force-pushed the fix/curing-collect-fanin-staleness branch 2 times, most recently from cbdc1da to 8deb88c Compare July 3, 2026 07:36
@TGPSKI
TGPSKI force-pushed the fix/high-volume-ci-load-test-fixes branch from c9d91c9 to da7db81 Compare July 3, 2026 07:40
@TGPSKI
TGPSKI marked this pull request as draft July 3, 2026 08:26
Base automatically changed from fix/curing-collect-fanin-staleness to main July 3, 2026 16:46
@TGPSKI
TGPSKI marked this pull request as ready for review July 3, 2026 16:49
TGPSKI added 13 commits July 3, 2026 23:22
http.Client{Timeout: ...} fired independently of the run's context
deadline, producing spurious "context deadline exceeded" errors well
before the real timeout under load. The context deadline (set once in
runner.Run / the curing's timeout_seconds) is now the single source of
truth for request timeouts.
New per-agent override that will send chat_template_kwargs.enable_thinking
= false to the model. Zero value (false) leaves default model behavior
untouched. Wired up to frontmatter parsing and the runner in following
commits.
Adds "thinking: false" as a recognized *.agent.md frontmatter key,
parsed into frontMatter.DisableThinking (inverted so the zero value
matches "no override, model default").
LoadFile now copies frontMatter.DisableThinking into the constructed
model.Agent, completing the thinking: false frontmatter feature.
Verifies "thinking: false" sets DisableThinking, and that it defaults
to false (model default, no override) when unset.
Three related fixes found while load-testing a fan-out/fan-in pipeline
against a real reasoning model (Qwen3 via vLLM, tool-call-parser
qwen3_xml). Confirmed via direct replay against the LLM endpoint outside
leather that this is model/parser flakiness under load, not a request-
construction bug in the runner (~25% of isolated replays reproduced it;
an explicit "don't do this" prompt instruction had zero effect).

- Duplicate tool-call guard: the model would occasionally re-issue an
  already-succeeded tool call verbatim instead of progressing, spinning
  until max tool rounds was hit. A tool call with the same name and
  arguments that already succeeded this run is no longer re-executed —
  the model is told it already happened and the run proceeds. Also
  guards against duplicating a real side effect (e.g. posting a comment
  twice). Scoped to non-hide tools; hide pagination tools legitimately
  expect repeated calls.

- Self-healing retry on empty final answers: a related manifestation of
  the same flakiness — the model would sometimes stop naturally
  (finish_reason "stop", not "length") with zero output tokens instead
  of the expected answer, propagating downstream as blank/N/A fields.
  Extended the existing length-truncation retry to also retry once, bare,
  on this case.

- DisableThinking wiring: merges chat_template_kwargs.enable_thinking =
  false into the request when Agent.DisableThinking is set, alongside
  the existing parallel_tool_calls override.

Also retains the tool-call-argument debug logging added during
investigation (agent/tool/args at DEBUG) — the byte-count-only logging
that existed before didn't reveal repeated-argument loops.
- TestRunner_DuplicateToolCallSkipsReExecution: a repeated identical
  tool call hits the real endpoint once, not twice.
- TestRun_SelfHealingRetry_OnEmptyStopCompletion /
  TestRun_EmptyStopRetry_IsSingleShot: empty finish_reason=stop retries
  exactly once and passes through if the retry is also empty.
- TestRun_DisableThinkingMergesChatTemplateKwargs: DisableThinking
  merges chat_template_kwargs without clobbering parallel_tool_calls.
…ents

- decision.agent.md: the prompt said "copy PR_NUMBER, REPO, SHA verbatim
  from ANALYSIS 1", assuming the 3 parallel analysis agents always
  arrive in submission order. Only pr-metadata emits those fields, but
  pr-context (zero tool calls) often won the race for slot 1, leaving
  nothing to copy and producing "PR_NUMBER: N/A". Now references the
  block by its "(from: pr-metadata)" tag instead of position.

- All 5 agents: added "thinking: false" and "completion_reserve: 768"
  (down from the reasoning-model default of 8192). Disabling the hidden
  reasoning trace was the most effective fix for both the tool-call-loop
  and empty-final-answer flakiness (see internal/runner fixes), and
  measured a 5.2x speedup on a 40-webhook burst load test (323s -> 62s)
  with equal-or-better correctness. See README.md for full numbers.

- pr-metadata.agent.md / pr-diff.agent.md: tool_rounds 2 -> 3, giving
  one extra round of headroom before the max-rounds guard trips.

- pr-comments.agent.md: tool_rounds 3 -> 5, and added an explicit "do
  not call post_pr_comment more than once per PR" instruction (kept as
  defense in depth alongside the runner-level dedupe guard, even though
  the instruction alone measurably had no effect on the underlying
  flakiness).
…t_jobs

llm_timeout no longer applies now that the HTTP client timeout race fix
(internal/session/http_client.go) makes the context deadline the single
timeout source — leaving it here was dead config that could confuse a
reader into thinking it still did something. max_concurrent_jobs raised
8 -> 16 to give the higher webhook volumes in this example room to run
without saturating the scheduler ahead of the per-queue concurrency
limits in tannery.yaml.
get_pr_files/get_pr_diff (20s/30s) and post_pr_comment/add_pr_label
(15s) were too tight once the pipeline was actually driving burst
concurrency against a real backend — bumped all four to 60s to match
the per-agent LLM call timeout, so a slow shell-mcp round trip under
load fails via the same timeout budget as everything else instead of
tripping first.
comments-in was pinned to concurrency: 1 (serial, ordered delivery) —
under real burst load this made pr-comments the pipeline's bottleneck,
queueing up behind every decision even though comment order across
different PRs doesn't need to be preserved. Raised to 8 to match the
other queues.
- Wait/drain reordering: the script waited for `decision` artifact count
  to reach WEBHOOK_COUNT, but decision isn't the pipeline's last stage —
  pr-comments is. The results/tally/done summary printed while
  pr-comments runs (and the server's own --pretty live trace, unredirected
  and sharing this terminal) were still active, making it look like
  output kept streaming after the script said it was done. Now waits on
  pr-comments artifact count (tracking decision count alongside it for
  visibility), and moved the queue-drain + server-shutdown step to run
  before the results/tally/done section instead of after.

- .env sourcing for LLM endpoint/model overrides without exporting them
  in the shell first.

- Shell robustness: `[ "$WEBHOOK_COUNT" -lt 25 ] && WEBHOOK_COUNT=25`
  style short-circuit clamping replaced with explicit if/then (the `&&`
  form exits non-zero under `set -e` when the condition is false,
  intermittently aborting the script); `read -r f || [ -n "$f" ]` guards
  against a last line without a trailing newline; `|| true` after find
  pipelines so a transient empty result doesn't trip `set -e`; capture()
  regex fixed to standard `(?<name>...)` syntax.
Adds a "Reliability & performance fixes" section covering all issues
found and fixed while load-testing this pipeline against a real vLLM
endpoint: the 8 framework-level and example-level bugs fixed across the
preceding commits, and the measured 5.2x speedup (323s -> 62s) from
disabling thinking mode and right-sizing completion_reserve.
@TGPSKI
TGPSKI force-pushed the fix/high-volume-ci-load-test-fixes branch from 50bfb3d to a22f660 Compare July 4, 2026 06:22
TGPSKI added 8 commits July 3, 2026 23:43
…nal answer

Load-testing examples/11-high-volume-ci surfaced another qwen3-under-load
flakiness mode: the model sometimes emits the head of its final answer
(e.g. the PR_NUMBER/REPO/SHA header) in the same completion as a tool
call, then — seeing that text in the session as already said — continues
from where it stopped after the tool result. The runner recorded only the
last round's content as the answer, silently head-truncating the artifact
(6/40 pr-metadata artifacts in a 40-webhook burst lost their identifier
header, cascading into blank or literal-placeholder decision reports and
garbage post_pr_comment calls downstream).

Answer fragments emitted in tool-call rounds are now banked and spliced
ahead of the final round's text, so the recorded turn, cache write, and
output routing all see the full answer. Scoped to non-hide rounds — text
next to hide pagination calls is navigation narration, not answer. The
bare empty-stop self-healing retry is skipped when fragments are banked:
an empty stop then just means the model had nothing left to add.
- TestRun_SplitAnswerAcrossToolRounds_IsReassembled: content emitted
  alongside a tool call is spliced ahead of the post-tool-result
  continuation in LastResponse.
- TestRun_SplitAnswer_EmptyFinalStopSkipsBareRetry: when the fragments
  already carry the whole answer and the final completion is an empty
  stop, the banked fragment is the answer and the empty-stop retry does
  not burn an extra completion.
New optional per-tool "patterns" map in shell-tools.json: argument key ->
RE2 regexp that the substituted value must match before the command runs.
A missing argument validates as the empty string, so anchored patterns
also reject absent values — catching a flaky model passing blanks or
literal prompt-template placeholders like "<number>" instead of real
values. Patterns are validated at startup (bad regexps fail fast) and
advertised in the tool's inputSchema so schema-aware clients see the
constraint too.
A 40-webhook burst produced 6 decision reports with blank or literal
placeholder ("<number>") PR identifiers when the pr-metadata artifact
lost its header (see the runner split-answer fix). Downstream, five
pr-comments runs dutifully posted comments to PR #<number> and one burned
its whole completion budget reasoning about the malformed input. Three
layers of defense:

- shell-tools.json: pattern-constrain pr_number (^[0-9]+$) and repo
  (owner/name) on all four tools, so garbage identifiers now fail loudly
  at the shell-mcp boundary instead of executing with placeholder text.
- decision.agent.md: on a missing/non-numeric pr-metadata block, emit
  Decision: ERROR with blank identifier fields rather than guessing or
  echoing template placeholders.
- pr-comments.agent.md: on Decision: ERROR or a blank/placeholder
  PR_NUMBER, make no tool calls and report the skip — preventing both
  the garbage posts and the completion-budget reasoning spiral.
Adds Unreleased entries that were missing for work already on this
branch and its base: the four curing-worker concurrency fixes (#45),
the HTTP client timeout race, the reasoning-model tool-call-loop
flakiness fixes (duplicate-call guard, empty-stop retry), the
split-answer head-truncation fix, the thinking: front-matter field,
and shell-mcp per-argument pattern validation.
… groups

Root cause of a 99/100 completion in a 100-webhook burst of
examples/11-high-volume-ci: ids.TimestampHex drew its uniqueness suffix
from mathrand.Int31n(0x10000) — 65,536 values per (prefix, minute)
bucket, ~1% birthday-collision odds at ~40 IDs/minute. Two concurrent
pr-context legs drew the same output hide ID (hide_pr_context_..._c461);
hide.Store.Put's os.MkdirAll silently merged them; PR #1019's decision
group collected the shared hide, succeeded, and deleted it; PR #1043's
group then found its leg's hide missing 6 seconds later and DLQ'd after
exhausting retries (the #45 requeueOrDLQGroup machinery surfaced the
loss exactly as designed). With the opposite race order the failure
would have been worse: a silently wrong decision built from another
PR's analysis content.

Two layers:
- ids.TimestampHex suffix widened to 32 bits (%08x): collision odds at
  hundreds of IDs per bucket drop to ~1 in a million.
- hide.Store.Put now creates the entry directory exclusively (os.Mkdir
  instead of MkdirAll) and regenerates the ID on collision, bounded at
  5 attempts — a drawn collision can no longer overwrite another hide's
  content out from under its queue references.

Note: the NamesContaining reference-check used by the prefix-scan GC
path was considered for handleCollected's cleanup as well, but it
matches hide IDs embedded in queue *names* (fan-out input hides) and
cannot see leg-output hides referenced only by queue item contents —
exclusive creation at the write point is the guard that actually closes
this class.
…rror

- TestStore_Put_RegeneratesIDOnCollision: a Put whose generated ID
  collides with an existing hide regenerates instead of reusing it, and
  the existing hide's content survives untouched.
- TestStore_Put_PersistentCollisionErrors: a permanently colliding ID
  generator makes Put fail loudly after bounded attempts rather than
  overwrite or loop forever.
Also tightens TestTimestampHexUniqueness: with a 32-bit suffix, any
collision in 1000 draws now fails the test instead of tolerating up to
100.
scripts/profile-run.sh wraps any command with 1-2 Hz samplers: host
CPU/memory/disk (vmstat, plus iostat/mpstat/pidstat when sysstat is
installed), kernel pressure-stall information, per-process attribution,
GPU telemetry via nvidia-smi, lm-sensors temperatures, and vLLM's
/metrics endpoint (running/waiting requests, KV-cache usage, token
throughput, prefix-cache hit rate). scripts/profile-summary.py renders
an avg/peak summary from any capture directory; sysstat and GPU
samplers degrade gracefully when absent.

examples/11-high-volume-ci/README.md gains a 'Measured at scale'
section with the profiled 100-webhook burst on a single RTX PRO 4500:
~500 LLM jobs, 972k tokens, 3.2k prompt tok/s sustained — orchestrated
for ~6% of one host's CPU, zero PSI stalls, and 2% peak disk util.
The pipeline is GPU-bound end to end; leather's orchestration overhead
is effectively free at this scale.
…/100 run

Replaces the interim numbers (from a run on the pre-ID-fix binary that
finished 99/100) with the canonical post-fix run: 100/100 through every
stage in 190s, 500 jobs / 965k tokens, 4.6k prompt tok/s sustained,
host at 6.5% CPU with zero PSI stalls, GPU 79.7% avg.
@TGPSKI
TGPSKI merged commit 87adc52 into main Jul 4, 2026
3 checks passed
@TGPSKI
TGPSKI deleted the fix/high-volume-ci-load-test-fixes branch July 4, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant