Skip to content

Model-call liveness: keepalive, waiting heartbeat, unreachable provider, refiner status (#77) - #78

Merged
wilke merged 1 commit into
chian:mainfrom
wilke:fix/model-call-liveness
Oct 8, 2026
Merged

wilke merged 1 commit into
chian:mainfrom
wilke:fix/model-call-liveness

Conversation

@wilke

@wilke wilke commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #77.

What happened (2026-10-08, refiner build on Argo)

  • 14:45:05: the Designer's second call was dispatched. Shortly after, the host left the ANL VPN. The socket (140.221.15.46 → Argo:443) became half-open, and the client was httpx.Client(timeout=None), so the read blocked forever.
  • ADR 0008 health probes ran (15:55, 16:01, 16:12, 16:33, 17:14). The first came 70 min in instead of 15, consistent with the Mac sleeping, since the monotonic clock pauses during sleep. Each probe failed with "Request timed out." before any response. The policy treats a failed probe as "preserve the original", so the call waited indefinitely.
  • /build status showed state: refining, model_wait: null throughout. model_wait only tracks the Builder's in-process calls, and since Construct workflows through scoped Episodes and update coding/viewer interfaces #66 the work happens in refiner Run calls recorded under the refiner's Duet.

Changes

  1. Dead connections fail. New agent/provider_http.py: provider clients use TCP keepalive (idle 60 s, interval 15 s, 4 probes, about 2 min to declare a vanished peer) and a 30 s connect timeout. Reads stay unbounded, because a live gateway may send nothing until a long completion finishes, and keepalive ACKs from a live peer keep the socket open. A dead peer surfaces as a read error, which Replace a model attempt whose connection dropped mid-response (ADR 0008 amendment) #76 replaces once. Used by the launch transport and the chat coding adapter.
  2. Waiting heartbeat. A silent call records call_waiting every 120 s, with idle seconds, phase, whether a probe is active, and the time to the next probe. A stall is now visible in the ledger, and "supervisor stopped" can be told apart from "still waiting".
  3. Unreachable provider ends the call. 3 consecutive health probes that get no response at all end the logical call with provider_unreachable and ModelCallRecoveryExhausted("Model provider unreachable …") instead of preserving it forever. "No response at all" means a connect failure, or a timeout while the probe was still in dispatching/request_dispatched, i.e. before response headers. A probe that received headers, or succeeded, resets the count, so a slow but live provider is unaffected.
  4. /build status while refining. model_wait now describes the build's newest model call from the ledger: episode, state, phase, idle seconds (also as elapsed_seconds for the status line), physical attempt, endpoint, and response_seen. It uses a new DuetStore.events_of_type() cross-Duet read, filtered by owner_duet_id. On the real stalled ledger it reports:
    {"active": true, "episode": "launch", "state": "original_preserved", "phase": "request_dispatched",
     "idle_seconds": 10433.6, "response_seen": false, "physical_attempt": 1,
     "endpoint": "https://apps.inside.anl.gov/argoapi/v1"}

Tests

tests/agent/test_model_call_liveness.py, 8 tests:

  • keepalive options;
  • connect bounded and read unbounded;
  • unanswered probes end the call as unreachable;
  • a probe that got headers doesn't count;
  • waiting heartbeats are recorded;
  • status for a long-silent call;
  • status follows the newest call;
  • events_of_type across Duets.

Unchanged and passing: test_model_call_failed_attempt_replacement (7), test_episode_model_failure_reporting (5), test_launch_transport_streams (4), test_refinement_chat_coding (14).

test_build_refinement_job.py::test_one_build_enters_refinement_and_cancel_stops_same_job[materialized-build-linked-refiner] fails identically on clean main; it is pre-existing and unrelated.

Not addressed

On macOS the monotonic clock stops during sleep, so probe scheduling ignores sleep time. Keepalive (1) covers the network-loss case regardless.

🤖 Generated with Claude Code

…er, refiner status (chian#77)

A refiner build on Argo stalled for hours (2026-10-08): the host left the ANL
VPN mid-request, the provider socket became half-open, and the client had no
read timeout. Every ADR 0008 health probe then timed out before any response,
which the policy treats as "preserve the original" — indefinitely — and
/build status showed model_wait: null because refiner calls bypass the
Builder's in-process wait tracker.

- agent/provider_http.py: provider clients get TCP keepalive (~2 min to declare
  a vanished peer) and a 30 s connect timeout; reads stay unbounded so long
  silent generations remain legal. Used by the launch transport and the chat
  coding adapter. A dead connection now fails as a read error, which the
  dropped-transport replacement (chian#76) handles.
- model_call_recovery: a silent call records call_waiting every 120 s (idle
  seconds, phase, probe state), so a stall is visible and a stopped supervisor
  is distinguishable from a waiting one. Three consecutive health probes that
  get no response at all (connect failure, or a timeout before response
  headers) end the call with provider_unreachable instead of preserving it
  forever; a probe that received headers resets the count.
- /build status: while refining, model_wait describes the build's newest
  model call from the ledger (episode, phase, idle seconds, attempt,
  endpoint, response_seen), via a new DuetStore.events_of_type cross-Duet read
  of model_launch_call events carrying owner_duet_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wilke
wilke merged commit 16c0d6f into chian:main Oct 8, 2026
33 of 52 checks passed
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.

Silent model call stalls a refiner build for >1 h: ADR 0008 health probe never starts

1 participant