Skip to content

fix(init): say what the dev-server wait is waiting for - #900

Open
divshekhar wants to merge 8 commits into
mainfrom
fix/init-says-what-it-is-waiting-for
Open

fix(init): say what the dev-server wait is waiting for#900
divshekhar wants to merge 8 commits into
mainfrom
fix/init-says-what-it-is-waiting-for

Conversation

@divshekhar

Copy link
Copy Markdown
Contributor

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:

server bound to [::1]:60952          (Node v22.14.0, macOS)
  http://localhost:60952/  -> 200
  http://127.0.0.1:60952/  -> ECONNREFUSED

urlToWatch composes http://localhost:${port} and probePage uses Node fetch, so the probe is not IPv4-pinned. Real Chromium through our own bundled Playwright reaches the same server through localhost too, 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.ts already 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

  • Silent for the first 15s. A normal start is sub-second; announcing a wait that is not happening is noise in the first command a user ever runs.
  • Spoken on an interval, not per poll. A line per poll would bury the output it exists to explain.
  • Pure, clock supplied by the caller — so it is testable without a real one and no test asserts a duration (the repo forbids those).

Gates

  • pnpm format:check, pnpm typecheck 22/22, pnpm test:unit (676 server files, setup suite 228/228)
  • eslint clean on the three touched files

Not run: test:e2e — no tool surface change. This is init output only; the install gate is the tier that would see it, and it is a print rather than a behaviour change.

divshekhar and others added 7 commits September 9, 2026 00:10
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>
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.

1 participant