Skip to content

Fix issues that made long runs fail or hang - #182

Open
chandrakananandi wants to merge 7 commits into
masterfrom
cnandi/run-hardening
Open

Fix issues that made long runs fail or hang#182
chandrakananandi wants to merge 7 commits into
masterfrom
cnandi/run-hardening

Conversation

@chandrakananandi

Copy link
Copy Markdown
Contributor

I was trying to run AutoProver (crucible on the solana_vault demo) via Fable and it had to do these fixes to get it working. I wanted to make a PR with the fixes, in case these might help others too.

From Claude:

  1. LLM calls died at 10 min, or hung forever. Non-streaming calls hit the Anthropic SDK's 10-minute limit, and our timeout=None disables timeouts completely, so a dropped connection hung the run indefinitely. Fix: stream responses (no limit) and time out after 5 min of silence. This will affects every Anthropic call in the repo. Is that ok?

  2. Doc search: broke under parallel agents. The embedding model isn't thread-safe: concurrent searches returned no results on CPU and segfaulted on Mac GPUs. Fix was to run one encode at a time; add COMPOSER_EMBED_DEVICE=cpu to skip the GPU.

  3. Report: empty despite successful work. State read back from Postgres sometimes arrives as plain dicts instead of typed objects, crashing the final step after all the paid LLM work was done. Fix was to re-validate on read. (Why the round-trip does this, and why an all-failed run still exits 0, are left open.)

chandrakananandi and others added 3 commits August 19, 2026 10:57
A long authoring turn (Opus thinking over a large prompt) can exceed the
SDK's 600s non-streaming ceiling, and `timeout=None` explicitly DISABLES
the SDK's timeouts (an explicit None is not not-given), so a socket that
died silently mid-call hung the session forever — both observed on
Crucible solana_vault runs. Stream every request so bytes keep flowing
(no ceiling, no idle window for NAT killers to hit), and bound each httpx
phase at 300s so a dead socket surfaces in minutes; for a streamed
response that bounds the silence between chunks, not the whole turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nomic model's remote code caches positional tensors per sequence
length, so concurrent encodes race: every concurrent crucible_docs_search
died with tensor-shape mismatches (the tool degrades to "no results", so
authoring ran ungrounded and hallucinated the crucible API), and on Apple
Silicon the auto-picked MPS backend segfaulted the whole process inside
torch's Metal shader cache. One process-wide lock serializes encodes --
queries are short, so contention is noise -- and COMPOSER_EMBED_DEVICE=cpu
lets a Mac host opt out of MPS entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
State read back through the Postgres checkpointer can carry raw dicts
where the schema declares models (the serializer's fallback when it
cannot reconstruct the class). The readback then died on "'dict' object
has no attribute 'property_title'" -- after every component session had
already finished its paid authoring -- and the campaign reported empty
with exit 0. Revalidate at the boundary so the readback is typed either
way. Symptom fix: why the serializer falls back at all is still open, and
other checkpoint readers may want the same guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ericeil

ericeil commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Report: empty despite successful work. State read back from Postgres sometimes arrives as plain dicts instead of typed objects, crashing the final step after all the paid LLM work was done. Fix was to re-validate on read. (Why the round-trip does this, and why an all-failed run still exits 0, are left open.)

I ran into this myself today. It looks like it's an issue with the new "tool family" thing. I don't think this is the right fix though; I will open a separate PR for this.

Comment thread composer/llm/anthropic.py
# prompt) can exceed the SDK's 600s non-streaming ceiling, and a silent
# 10-minute wait is long enough for NAT/idle killers to drop the socket
# (surfaces as APIConnectionError mid-run). Streaming keeps bytes flowing.
streaming=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I happen to know this causes the langgraph api to produce streaming results in its own API. Please double check this doesn't utterly break the TUI and console display handlers, i.e. we aren't streaming chunks that our handlers have no idea what to do with, this somehow opts us out of the complete results, etc.

Comment thread composer/rag/db.py
Comment on lines -102 to +125
return cast(ndarray, await asyncio.to_thread(
self.tr.encode_query, f"search_query: {query}", show_progress_bar=False
))
return await asyncio.to_thread(
self._encode_query_locked, f"search_query: {query}"
)

async def embed_docs(
self, doc: list[BlockChunk]
) -> list[ndarray]:
return cast(list[ndarray], await asyncio.to_thread(
self.tr.encode_document, [f"search_document: {d.chunk}" for d in doc], show_progress_bar=False
))
return await asyncio.to_thread(
self._encode_docs_locked, [f"search_document: {d.chunk}" for d in doc]
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this should also be applied to the DefaultEmbedder found in the indexed research agents, although there the API is sync so we can't just offload to to_thread....

Comment thread composer/rustapp/session.py Outdated
Comment on lines +944 to +950
skipped=_revive(SkippedProperty, state["skipped"]),
property_checks=[
(m.property_title, m.checks)
for m in _revive(PropertyCheckMapping, state["property_checks"])
],
verdicts={k: _revive_one(WireVerdict, v) for k, v in state["verdicts"].items()},
ran=_revive(Target, state["ran"]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ugh, this has bitten us before. It's unclear to me why langgraph sometimes deserializes the type, sometimes hands us back the raw dict. Having to add this defensive coding is madness, but idk what to do ...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hm, I guess if this is a pre-existing condition then ignore my previous comment. :) I'll look closer at the "tool family" problem.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ok, yeah, this was a pre-existing condition that also affected the new shared tools. :) See Certora/graphcore#35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

From the description of Certora/graphcore#35:

A checkpoint serializer (JsonPlusSerializer) names a pydantic value by cls.module and
cls.name, then restores it with getattr(import_module(module), name)(**kwargs). On failure
it returns the kwargs dict.

create_model takes module from the calling frame, so both the bound clone and a rendering
of it claimed graphcore.tools.schemas. That name is not there, so a rendered value comes back as
a bare dict and the next read of it fails on an attribute it no longer has:

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.

so should we keep this change or no? Seems like it can help, no?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should revert this part of the change; my graphcore change should make this unnecessary. BTW the AutoProver side of that change is here, if anyone wants to approve: #184

@chandrakananandi

Copy link
Copy Markdown
Contributor Author

Report: empty despite successful work. State read back from Postgres sometimes arrives as plain dicts instead of typed objects, crashing the final step after all the paid LLM work was done. Fix was to re-validate on read. (Why the round-trip does this, and why an all-failed run still exits 0, are left open.)

I ran into this myself today. It looks like it's an issue with the new "tool family" thing. I don't think this is the right fix though; I will open a separate PR for this.

Should I undo this then?

chandrakananandi and others added 2 commits August 21, 2026 15:05
A streamed response carries no raw response_metadata["usage"] dict (the
non-streaming path's shape), so with streaming on, UsageCallback recorded
zero tokens for every call while costs kept accumulating correctly
(CostAccumulator already reads the normalized usage_metadata). Fall back
to usage_metadata when the raw dict is absent, translating normalized
input (total, cache included) back to the raw shape (cache excluded).
Verified against live streamed and non-streamed responses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: the indexed research agents embed through
DefaultEmbedder's sync Embeddings API, which called the shared
sentence-transformer with no serialization -- the same race the async
ComposerRAGDB wrappers just got a lock for. The lock now lives in
composer.rag.models and both paths take it, so sync and async encodes
cannot race each other on the same model instance either. The sync API
takes the threading.Lock directly (no to_thread needed -- callers are
already off the event loop or tolerate the short block; encodes are
sub-second). Verified with interleaved sync-thread + async encode hammer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants