fix(init): say what the dev-server wait is waiting for - #900
Open
divshekhar wants to merge 8 commits into
Open
Conversation
An app rewrote one localStorage key thousands of times a minute with
byte-identical content. Every one of those no-ops took a ring-buffer slot:
`held: 2000, dropped: 70482`. The verdict taken in that window then reported
`net.total: 0`, `stateDiffs: []` and "state never changed" — while a POST that
had returned 200 inside the same window carried the entire root cause in its
body. The agent read the zero and was one step from reporting "clicking Accept
fires no network request", which sends a developer to the click handler instead
of to the payload the server rejected.
Dropped at the observer rather than at the verdict: the cost is the buffer slot,
and a diff whose `old` equals its `new` carries nothing to render either way.
The same no-ops were padding verdicts with twenty identical entries and
`elided: { storageDiffs: 1881 }`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
`honesty.integrity.losses` already carried `buffer_loss`, and it was not enough. The fields an agent reads to form a verdict are `summary.net.total`, `stateDiffs` and `firstDivergence`, and a bare `0` several levels above a nested integrity flag reads as a fact. In the field it produced a wrong root cause. With the buffer starved, one verdict reported `net.total: 0`, `stateDiffs: []` and `state "cad" never changed` — while a POST that returned 200 in that exact window carried the whole explanation in its body. The agent concluded that clicking Accept "does nothing — no network request is made", which points a developer at the click handler instead of at the payload the server rejected. A tool whose purpose is to stop an agent inventing explanations handed it a fabricated one, quietly enough that it nearly shipped. That is worse than a crash: a crash is obvious, this is a confident, specific, wrong number. So `CausalSummary` carries `truncated`, and `firstDivergence` stops asserting what did not happen over a window it did not fully see: "no surviving event shows a request to /apply — capture truncated, so this is not evidence there was none". A consequence answered by an event that DID survive is still stated plainly; the caveat qualifies absence, never evidence. The absolute and the caveat must not be separable. Absence of evidence must not be typed as evidence of absence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
…the step
The wait for a step's declared consequence was a fixed 4s with no env var, no
flow field and no replay parameter to raise it. That is not a tuning knob — it
decides whether an honest flow can ever be green.
Two field reports, the same shape. A login whose POST measures 5.5s against a
remote Postgres, and a geometry import whose model-backed perception takes ~22s.
Both were verified live with `act_and_wait { timeout_ms }` and returned
`verified: "yes"`. The identical saved flow drifted at ~4020ms with
`signal_not_observed`, and the summary said NO LONGER TRUE — a working feature
reported to the user as a regression. The only ways to green them were to weaken
or delete the assertion, which the rules correctly forbid, so the flow stayed
honest and permanently red.
One reporter tried a step-level `timeout_ms` and an `expect.timeout_ms`. Both
were dropped in silence, which is the worse half: a knob that reads as accepted
and does nothing.
`FlowStep.timeoutMs` wins, then `FlowFile.signalTimeoutMs`, then the shipped
default. Per step rather than per flow, because one slow step in an otherwise
fast journey is the common shape and making the whole suite wait for the slowest
step trades a false red for a slow gate. The success oracle honours the flow's
declaration for the same reason — greening every step and then failing the
outcome at 4s is the same false red one layer down.
Optional and back-compat: a flow declaring neither replays exactly as before and
the on-disk version stays FLOW_FILE_VERSION 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
The recorder and the replayer disagreed about what an upload step IS, and the
disagreement was total. `reticle_record` writes
`{"action":"upload","args":{"path":"test-fixtures/pipe.step"}}` — the only form
the live `reticle_act` accepts, and the form that works interactively. Replay
dispatched it straight at the browser, which refused: "upload does not read
path, so it would be dropped". So a recorded upload could never replay as
recorded, and no flow touching a file upload could be green — which rules out
document ingestion, avatar upload, CSV import.
The capability was there the whole time. An agent who hand-patched the flow JSON
to `{name, content, type}` got a real upload, a real `POST /api/v0/files -> 200`,
and the app went on to perceive the file. Only the wiring was missing:
`rewriteUploadArgs` is called from `actCommand`, and replay does not go through
it.
Resolved once, before step 1, through that same helper — so the two paths cannot
drift again, which is the whole reason this existed. Not at save time: the flow
file stays small and legible, the fixture stays on disk where it can be reviewed
and updated, and a flow naming a file nobody checked in fails saying so instead
of carrying a stale copy of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
… spinning Absorbing a disconnect and continuing is right for ONE disconnect: the client closed a socket, the proxy rebuilds it. It is catastrophic for a stream of them, because the write that failed is retried at once, fails identically, and is absorbed again — a tight loop with no backoff and no exit. Measured in the field: one `reticle mcp` ran four days after its editor closed, at 97-98% of a core, 1473 minutes of CPU time, writing ~930 MB/hour of identical `client_disconnected` EPIPE lines with every entry stamped to the same millisecond. Log rotation kept the disk footprint at 17 MB, which hid ~22 GB/day of SSD writes and a permanently burnt core. Nothing in Reticle's own output showed it — the daemon beside it reported healthy with `sessions: 0` — so it was findable only by running `ps` by hand. Twenty absorbed disconnects is well above the handful a genuine reconnect produces and far below a runaway. The proxy exists to be the stdio server its editor launched; once that editor is gone there is nobody left to serve, and the correct thing is to leave rather than burn a core proving it. ECONNREFUSED is untouched — nobody went away there, the daemon has not booted yet, and the proxy is built to tolerate that and wake it. The idle pairs from earlier sessions are the other half of the same report and are filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
… key
`write-field-ignored` overrode a passing assertion to `verified: "no"` on a
write that had applied correctly.
The request asked `{"slot":"_dim","value":"3D"}`. The server applied it —
`brief.dim: "3D"`, and the interview advanced from `{kind:"question"}` to
`{kind:"geometry", payload:{module:"fluids", dim:"3D"}}`. The kind reported "its
own echo shows 1 field(s) NOT applied — value: asked 3d, got cfd", where `cfd`
is `brief.slots._family.value`: a different slot, set earlier in the same
interview.
A container key cannot be compared across documents by name. `value` in the
request means "the value of the field named by `slot`"; `value` in the response
means "the value of whichever field this node belongs to", and a document
holding many addressed fields holds many `value` nodes. The flat by-key
comparison was measuring the request's value against an unrelated sibling's.
Proof it was the heuristic and not the app: the next call on the same endpoint,
`{"slot":"geometry.openings.main_top.boundary_condition","value":"inlet"}`, does
mirror into `slots.*` and passed clean. So it misfired exactly where a server
persists an addressed field somewhere other than a mirror named after it — a
normal API shape.
A manufactured red costs more than a missed one here. Reticle's proposition is
that only green means green and that an agent must not talk itself past a red;
a heuristic that invents them taught the reporter, inside one session, that a
`contradicted` verdict may be noise worth arguing with. That is precisely the
reflex this product exists to suppress.
Narrow, in this file's idiom: the addressing key itself is still compared, so a
server that echoes a DIFFERENT slot than the one requested is still a real
dropped write and still says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
The wait loop polled in complete silence. Reported as thirty minutes of nothing — no output, no exit, no verdict — ending in a SIGKILL. The cause of that particular wait is still unexplained, and I am deliberately not guessing at it: the reporter's IPv4/IPv6 hypothesis does not reproduce (measured both halves — Node `fetch` and real Chromium both reach an `::1`-only server through `localhost`), and the loop's own ceilings say a silent wait should end at 45s. That investigation is on the issue. The silence is a failure on its own terms and does not depend on knowing why the wait was long. A user cannot tell "still starting" from "wedged" without being told which, and cannot see the thing most likely to be wrong: the URL being watched rather than the server itself. That distinction is not hypothetical — `dev-server-wait.ts` already reasons about a server that serves happily on a port we never found, the CRA case where no url is printed outside a tty, and has never said so out loud while it is happening. Fifteen seconds, so an ordinary sub-second start prints nothing: announcing a wait that is not happening is noise in the first command a user ever runs. Spoken on an interval rather than per poll, or a line per poll would bury the output it exists to explain. Pure, with the clock supplied by the caller, so the decision is testable without one and no test asserts a duration. Part of #884. Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
divshekhar
enabled auto-merge (squash)
September 8, 2026 18:55
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Part of #884 — the half that holds whatever made the wait long.
What I did not do
The issue's stated cause is that we probe over IPv4 while Vite binds
::1. I measured both halves and neither reproduces, so I have not built a dual-family fallback:urlToWatchcomposeshttp://localhost:${port}andprobePageuses Nodefetch, so the probe is not IPv4-pinned. Real Chromium through our own bundled Playwright reaches the same server throughlocalhosttoo, so report B's "Playwright resolves localhost to 127.0.0.1" does not hold here either. Full write-up and the three data points that would settle it are on the issue.What this fixes
The wait loop polled in complete silence — thirty minutes of no output, no exit, no verdict, ending in a SIGKILL. That is a failure on its own terms and independent of the unknown root cause.
Every 15s it now says how long it has waited and which URL it is watching. The URL is the part most likely to be wrong: a dev server up on a port we never found looks identical to no dev server at all.
dev-server-wait.tsalready reasons about exactly that internally — the CRA case, where no url is printed outside a tty — and has never said it out loud while it happens.When nothing has been announced and no port was observed, it says that instead, and points at
--url. That is a different problem with a different action.Decisions
Gates
pnpm format:check,pnpm typecheck22/22,pnpm test:unit(676 server files, setup suite 228/228)Not run:
test:e2e— no tool surface change. This isinitoutput only; the install gate is the tier that would see it, and it is a print rather than a behaviour change.