This repository was archived by the owner on Jul 24, 2026. It is now read-only.
fix: make an always-on agent reachable, and stop reporting liveness it hasn't earned (reopened from #106) - #108
Draft
schickling-assistant wants to merge 1 commit into
Draft
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Restores the content of PR #106, which was merged by an agent account without the maintainer's review or agreement, and was therefore reverted in PR #107. This reopens it as a draft so it can be reviewed properly before landing.
The diff is byte-identical to what #106 contained — no content was changed while re-landing it.
Refs #101, #102.
The two problems
1. A ding sidecar could advertise
availablewhile sitting on an undelivered message.st dingrefreshed the watched identity's status mtime on a timer entirely decoupled from whether it was actually delivering. Root cause is inguardedDeliver: on the no-input branch a changing frame returnsheldunconditionally, andforceCapis computed but only consulted on the un-submitted-input branch — so the hold has no upper bound. A pane that is never byte-static across the 300ms diff (the normal state of a working agent) parks every poke for as long as it keeps working. WithST_DING_DEBUGoff — the production posture — stderr was completely silent. A sender read a healthy, available recipient and got nothing.The hold decision itself is correct and is kept: submitting into an active Claude Code turn seeds CC's queued-input replay bug, and there is an explicit regression test forbidding a force-submit. What was wrong is that the sidecar lied about it. So past the hold cap it now warns loudly on stderr and suspends the status heartbeat, freezing the mtime so readers derive staleness through the existing path. Invariant: a sidecar must not write liveness it has not earned.
2.
st agentsreportedavailablefor an identity thatconvoy ls --treecalledDEAD (status stale 3m ago)at the same instant. Worth noting for review: the original issue's premise — thatst agentsapplies no freshness window — turned out to be wrong. It reads throughreadIdentityStatus→readState, which already appliesSTATUS_STALE_MS. This is a threshold mismatch, not a missing check.The two windows answer different questions: the existing one asks "do we still trust this value?" and is sized for the slowest writer (the MCP server's 5-minute refresh), hence 15 minutes. Convoy's asks "is this agent live right now?" and is sized for the ding's 30s heartbeat, hence ~2 minutes. Tightening
STATUS_STALE_MSto ~120s would flap every MCP-refreshed agent intounknownbetween refreshes. So this names both windows instead:STATUS_LIVENESS_MS(2 min) alongsideSTATUS_STALE_MS(15 min), and addsreadIdentityLiveness()— one reader returning both verdicts plus the recorded value and mtime, exported from the package index so consumers inherit a definition instead of each inventing one.Please look closely at these
The roster states age rather than rendering a verdict. The third commit reverses a judgement call from the second, and is kept separate for that reason. The second commit rendered a non-live status as
available (stale 3m)— butst agentsenumerates a mixed population whose writers do not share a cadence, so a perfectly healthy MCP-backed agent (5-min refresh) judged against a 2-minute window readsstalefor three of every five minutes. That is the same trap one layer up: swapping "reports dead agents as available" for "reports live agents as stale". So the roster now states the age and lets the reader judge:A verdict still exists where it can be justified:
livestays in--json, andreadIdentityLivenesstakes alivenessMsoverride so a consumer that knows its agents run a ding keeps a definite answer.Semantics change worth reviewing deliberately. The derived
statusfield is unchanged, and so is what--statusfilters on. What changes is the rendered text cell ofst agents(now qualified when not live) and the JSON shape (additivelive/statusMtimeMs/recorded). Scripts parsing column 2 of the text output will see a qualifier they did not see before.The fourth commit fixes a defect the first one introduced — please check the reasoning holds. Suspending the heartbeat on a held message was cleared only on successful delivery. But a held message's most likely fate is never being delivered at all: the agent reads and archives it itself (the documented boot ritual is "drain your inbox"), the buffered poke is dropped as stale, and the stall becomes permanent. Net effect: a transient "unreachable while busy" became a permanent "reads dead" for a healthy agent — worse than the defect it set out to fix. The stall is now cleared on the drained checkpoints, which is precisely "no message remains undeliverable", rather than on any single message ceasing to need delivery (which would resume the heartbeat while a second message is still genuinely stuck).
Also in that commit:
readIdentityLivenessdocumented "single stat + single read" while performing 4 stats + 2 reads, andstatusandrecordedcame from two different reads — so a concurrent status write could return a pair that never existed on disk. Now derived from one stat + one read.recordeddeliberately survives both windows: an identity that wasbusyand went quiet is not the same fact as one that cleanly wentoffline.statusMtimeMsis an absolute mtime rather than an age so two back-to-back reads of an unchanged bus still compare equal (anageMsfield broke that invariant, caught by bus-reader's repeatability test).Verification
The ding stall was reproduced against an isolated root with a stub
pty, never against a live bus.On this branch:
nix flake check(the CI gate) passes —completions,typecheck,help. Unit suite green at 49 files / 1323 tests. The repo's integration suite has 16 failing files / 26 failing tests, but those are pre-existing and environmental —mainfails the identical set with or without this change.Note on deployment
This never reached production. The consuming
flake.lockpins smalltalk at850266f8(the merge of #105), which is below #106, so no deployed system ever ran this code.