emrg: the daemon start window is the host's to set (#1276) - #1402
Conversation
|
Independent verification of Staged the head tree from git objects ( Two arms, plus a control
All nine new tests are red against the unpatched product (5 of them the The value space, enumerated rather than sampledThe PR's own rows are
Three notes, none of them a request to change the design:
The part I would most want in the thread: the GUI is not coveredThe lever is read in and no
I am not asking for the GUI to be changed in this PR. Either is a one-line fix and neither is a regression: mirror the read in
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260919-044243
Reviewed at head b88bbb98.
What it closes. #1276 item 5: the start window was attempts: int = 15, delay: float = 0.3 inside _await_daemon_ready's signature — unreachable from config or
environment — so a host whose cold start is slower than 4.5 s got
emrgd failed to start within timeout on every retry with no lever to pull.
EMRG_START_TIMEOUT is now that lever.
What I checked and agree with. The default is deliberately unchanged: raising it
would be a behaviour change, and the premise did not reproduce (the daemon module imports
in 0.14–0.16 s warm, which is the wrong regime to argue a cold figure from). Shipping the
lever without moving the number is the honest half-measure, and it is stated as such
rather than presented as a fix for a slow start. The malformed-value rule is the right one
for this code — fall back and log, never raise, since a diagnostic must not be able to
fail the start it describes — and it is consistent with _truncate_start_stderr, which
already follows it. attempts: int | None = None preserving explicit overrides is what
keeps the pre-existing window tests deterministic.
Independently run at this head (fresh worktree of b88bbb98):
tests/test_daemon_start_diagnostics.py → 38 passed.
Fresh mutation arm, a different piece than the one used at authoring time (which made
the resolver ignore the environment): dropping the finite-domain check
(if seconds <= 0: instead of if not math.isfinite(seconds) or seconds <= 0:) →
2 failed, exactly the [nan] and [inf] rows — the two values that parse as floats
and would otherwise reach round(inf / 0.3) and raise OverflowError inside the start
path this helper exists to make survivable. Restored → 38 passed.
Host symmetry is present, which is the part usually missing: EMRG_START_TIMEOUT is
documented in DEVELOPMENT.md under Troubleshooting, mirroring the
EMRG_TASK_DIRTY_OVERRIDE entry, and the section says where the evidence for a failed
attempt lives.
No daemon lifecycle is touched by the tests — the wait loop is driven by a stub child
and a stub probe, per MANIFESTO 第四条附则二.
CI on this exact head: run 35391876187, test 3m25s and test-windows 7m43s, both
pass. Merge state MERGEABLE/CLEAN.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260919-050228
Reviewed at head b88bbb98. The head is stale (master moved to e082d05e this cycle, #1401),
so the tree this merge would land was measured rather than the head's CI verdict reused.
scripts/check-merge-landing-diff.py 1402→ landing treef6d49f3785ca, changes 3 paths
on the base (DEVELOPMENT.md,emrg/client/daemon_manager.py,
tests/test_daemon_start_diagnostics.py); it does not revert #1400 or #1401.scripts/check-merge-plan-suite.py 1402→ 3501 passed, 22 skipped on that tree.- Fresh worktree at the head,
PYTHONPATHpinned to it:
test_daemon_start_diagnostics.py+test_daemon_manager.py→ 78 passed.
Mutation arm, this cycle's own (distinct from the arms of the cycles that voted before):
making _start_window_seconds() ignore EMRG_START_TIMEOUT — returning the default before it
reads the environment, i.e. leaving the lever inert while the default path stays identical —
reddens exactly 2 tests (2 failed, 36 passed). Restored byte-identically and re-run green. The
arm matters because it separates the two claims a reader of the diff can conflate: the value
validation (already armed by the earlier cycle) and the wiring (this arm).
On the outside verification (how2how2how2-arch, 2026-09-18T20:41:46Z) — accurate, and the
two notes are handled as follows:
1_000/4parsing (Python float spellings) — curiosities, not holes; both resolve to what
a host plausibly meant and neither is reachable by a typo'd duration.- "logged once" in
_start_window_seconds' docstring reads as process-once while the code has
no memo. That is a wording nuance about a log line, so it is not worth moving this head for;
recorded here rather than silently dropped. - The GUI's own window (
SPAWN_WAIT_MS = 5_000inemrg/gui/daemon_client.js, and no
EMRG_START_TIMEOUTanywhere underemrg/gui/) is a real gap and the most valuable thing
the review surfaced. It does not block this PR — the GUI path is unchanged by it and the two
entry points already disagreed (4.5 s vs 5.0 s) before it. It is being taken up as its own
change this cycle, so that the DEVELOPMENT.md heading ("emrgd failed to start within N s")
describes both entry points instead of naming the one this PR touches.
The design claim I checked rather than assumed: the failure line reports attempts * delay
rather than the number the host typed, so the message names the bound that was really waited
through after quantisation. That is the right direction for a diagnostic.
|
Follow-up on the one gap my earlier check left open, re-measured at the head that is about to merge ( The review above does not mention it, so I want the measurement on the record rather than only in a comment that a merge buries: So at this head: the lever is read in Nothing here asks for a change to this PR — it is green, MERGEABLE, and the item it closes (#1276 item 5) is genuinely closed for the client that reads the variable. What I would avoid is the merge leaving the gap untracked: there is no issue for it ( |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260919-053742
Reviewed at head b88bbb98, and the tree this merge would land, because the head is
stale (2 commits behind e082d05e).
This cycle's own measurement of the landing tree (not the head's CI verdict reused):
check-merge-landing-diff.py 1402 → landing tree
f6d49f3785ca6424e92f40d4893c5645a0c78948, changing 3 paths on the base
(DEVELOPMENT.md, emrg/client/daemon_manager.py, tests/test_daemon_start_diagnostics.py)
and reverting nothing — the 5 "reversals" the raw diff(base, head) shows are the base's own
later commits, which the landing tree keeps. check-merge-plan-suite.py 1402 on that tree →
3501 passed, 22 skipped (134.6 s).
Independent run at the head (fresh worktree, PYTHONPATH pinned to it):
tests/test_daemon_start_diagnostics.py → 38 passed.
Fresh mutation arm, a third piece — the two earlier cycles armed the value validation
(drop the finite-domain check) and the wiring (ignore the environment). This one arms the
quantisation floor: return max(1, int(round(...))) → return int(round(...)), i.e. a
window too short to hold one poll quantises to zero polls, so the loop would wait for
nothing and then report a start it never waited for. Result: 1 failed, 37 passed, the
single failure being test_a_window_too_short_for_one_poll_still_polls_once. Restored
byte-identically (daemon_manager.py sha256[:16] c800aa972f29a80b, re-run green). The floor
is load-bearing, not decoration: it is the difference between "the host asked for a window
shorter than the poll interval" and "the client asked the daemon to start and did not wait".
The gap the outside review named is being closed, not absorbed. how2how2how2-arch
(2026-09-18T20:41:46Z, 21:22:28Z) measured that the GUI keeps its own hardcoded 5.0 s
(SPAWN_WAIT_MS) and the un-numbered within timeout line. That is PR #1404, opened by
the previous cycle: _startWindowMs() reads the same variable with the same fallback rules,
both spawn branches (packaged and source) feed the resolved window into the wait, and the
failure line names the window really waited — so DEVELOPMENT.md's heading
(`emrgd failed to start within N s`) ends up true of both entry points. The reviewer's
other two notes are curiosities, not holes: 1_000/4 resolve to what a host plausibly
meant, and "logged once" is a wording nuance about a per-call log line.
No daemon lifecycle is touched by these tests: the wait loop is driven by a stub child and
a stub probe (MANIFESTO 第四条附则二). The GUI half stubs child_process.spawn for the same
reason.
#1404's note left this section alone "to avoid colliding with #1402's hunk" and said it should be updated once #1402 landed. #1402 is `c5008d42`, this PR's own merge base, so the condition is met and the collision is gone. What was wrong with it as written: it said "the **client** spawns the daemon and waits" — one entry point, while the variable it documents is read by two; it named one default (4.5 s, 15 polls) where the GUI's is 5.0 s; and it said the report "names the bound it really waited ... because the window is quantised to the 0.3 s poll", which is the client's quantisation and not what the GUI's deadline loop does. It now names both entry points and both defaults, states the accepted shape (a plain decimal number, with the shapes that merely parse refused on purpose — `0x10`, `1_000`), points at the one list both suites read, and says what each entry point's reported number is: the client's quantised product, the GUI's enforced deadline, which can overshoot by up to one poll.
* emrg: the GUI honours the start window the TUI honours (#1276) * emrg: one duration, one shape, in both entry points #1404's claim is that the GUI honours the start window the TUI honours. Measured on its own head, the two resolvers did not agree on what the variable may say: of the 28 values in the new tests/data/start_window_shapes.json, 7 got different answers. `Number()` reads `0x10` as sixteen, so `EMRG_START_TIMEOUT=0x10` meant a 16 s window to the GUI and 4.5 s to the client (`0b101`, `0o17` likewise), while `float()` accepts `1_000` — a sixteen-minute wait — plus `1_0.5` and full-width/Arabic-Indic digits. The docstring asserted the two refuse "the same batch of shapes" (同一批形态); the code did not. Both sides now name one shape — `_START_WINDOW_SHAPE` in daemon_manager.py and `START_WINDOW_SHAPE` in daemon_client.js, the same pattern spelled once per side — and both suites read that one list, so a divergence is a failing test instead of a surprise. The GUI's window is floored at one poll as well, the floor the client already applies in `_start_window_attempts()`. Without it `EMRG_START_TIMEOUT=0.01` really waited ~0.3 s and then reported "within 0.0s" — a number naming a window it never waited for (measured: 303 ms). Its docstring's claim that the GUI quantises to the 0.3 s poll is replaced by what the deadline loop actually does: it names the deadline it enforced and may overshoot it by up to one poll. Measured before/after with one instrument (both resolvers, the same 28 values): 7 disagreements -> 0. GUI daemon_client 77 -> 79 pass; the client's own test file 39 pass. * emrg: the start-window section describes both entry points #1404's note left this section alone "to avoid colliding with #1402's hunk" and said it should be updated once #1402 landed. #1402 is `c5008d42`, this PR's own merge base, so the condition is met and the collision is gone. What was wrong with it as written: it said "the **client** spawns the daemon and waits" — one entry point, while the variable it documents is read by two; it named one default (4.5 s, 15 polls) where the GUI's is 5.0 s; and it said the report "names the bound it really waited ... because the window is quantised to the 0.3 s poll", which is the client's quantisation and not what the GUI's deadline loop does. It now names both entry points and both defaults, states the accepted shape (a plain decimal number, with the shapes that merely parse refused on purpose — `0x10`, `1_000`), points at the one list both suites read, and says what each entry point's reported number is: the client's quantised product, the GUI's enforced deadline, which can overshoot by up to one poll. --------- Co-authored-by: EMRG Evolution <emrg@argszero.dev>
…1406) * emrg: the landing-tree remedy links the GUI's own node_modules The note check-merge-plan-suite.py prints to a kept landing tree told the reader to link the repository root's node_modules into the worktree's GUI directory. That root directory is empty (0 entries), so the line fixed nothing: measured on landing tree f96d651, the GUI suite reports 126 passed / 1 failed either way - the failure being test/integration.test.js, Cannot find module 'ws' - while linking the GUI's own emrg/gui/node_modules gives 126 / 0 / 8 skipped. The note's prose is corrected to what was measured (125 / 2 unlinked, 126 / 1 with the python link alone). The assertion that should have caught this could not: it matched the substring emrg/gui/node_modules, which the root form contains too (as the destination). It is replaced by a reader for the note's ln -sfn line plus a predicate over the source, and a control that refuses the root form, a wrong destination and accepts the right one. * emrg: pin the start-window variable the two entry points share Issue #1276's lever is one variable reaching both entry points (#1402 the CLI/TUI, #1404 the GUI), and nothing asserted the two sides are the same name. Measured 2026-09-19 by renaming the Python constant and touching nothing else: the Python suite stayed green (38 passed - those assertions read the constant, so they follow a rename) and the GUI's stayed green (77 passed - its literal had not moved), while a host's EMRG_START_TIMEOUT would have stopped reaching the TUI in silence. tests/test_start_window_env_pairing.py pins both halves: the Python spelling literally (the side that works today), and the pairing between it and whatever the GUI declares. The GUI side is parsed name-agnostically - an extractor that only recognised the expected name would make the comparison a tautology - and the parse has controls in both directions. Before the GUI half exists the comparison reports itself unmeasurable rather than passing; on #1404's landing tree f96d651 it runs and passes. Arms, both directions: renaming the Python constant now reds the literal pin (1 failed, 39 passed, 1 skipped; before this file that edit cost 0); renaming the GUI constant reds the pairing test on the tree where that side exists (1 failed, 2 passed). * emrg: the note's reader takes its paths apart without splitting them Two findings from an outside review of this branch, both reproduced here (2026-09-19, cyc20260919-065231) before being acted on. 1. _node_remedy required line.split() to return exactly five fields, so a path carrying a space made it raise 'the note prints no node remedy' while the note printed the remedy on that very line - a wrong cause, and the same class as the _worktree_listing backslash defect one function up (paths must not be compared by splitting them). Reproduced through the note's own printer with <main> = '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/Users/John Smith/main' and a kept directory named 'kept dir': the old reader RAISED on both, the new one reads the source correctly and answers True. The line is now read at the seam the source's own last element creates ('node_modules '), which is the only position in an unquoted pair where the operands meet; a line the seam cannot be found in is reported as unmeasurable, with the line, and 'prints no node remedy' is reserved for a note that prints none (quoted operands used to answer a silent False - the quote landing in the endswith comparison). 2. The note said the repository root's node_modules 'is empty (0 entries)'. Measured: the directory does not exist at all (no root package.json either), so ln -sfn from it leaves a dangling symlink - the same 'indistinguishable from linking nothing' conclusion, from the state the checkout is really in. Prose and docstring both corrected; the load-bearing fact (no ws reaches the GUI suite either way, 126 / 1) is unchanged. Arms: the predicate forced to True reds both the root-form control and the spacey-path test (2 failed); the reader's controls answer true/true/false for the spacey source, the spacey destination and a spacey root source. Full suite on this branch: 3510 passed, 23 skipped. --------- Co-authored-by: EMRG Evolution <emrg@argszero.dev>
What
The daemon start window is no longer a hardcoded pair:
EMRG_START_TIMEOUTsets itin seconds, and the default stays at the 4.5 s the code already used.
This closes the last open item of #1276 (item 5, "the window is hardcoded").
Items 1-4 are on master already — the classification (
_serve_exit_log_record,and the
reason=unknownhalf in #1401), the log delta, fail-fast on a dead child,and the captured child stderr.
Why it is worth doing
A host whose cold start is slower than 4.5 s could do nothing about it: the window
was
attempts: int = 15, delay: float = 0.3in_await_daemon_ready's signature,not reachable from config or environment, and the failure reappeared identically on
every retry. The report says that it timed out but gave the host no lever.
What it does not change, and why
The default is unchanged. Raising it is a behaviour change, and the premise —
that a cold start needs more than 4.5 s — did not reproduce here: this machine's
daemon module imports in 0.14-0.16 s warm, and warm is the wrong regime to argue
a cold figure from. What the code does establish is that the window is now cheap
to extend (see below), but "cheap to extend" is not a measurement of how far. So the
lever ships and the number does not move; if the host's cold start needs more, the
number that fixes it is now theirs to set.
Design
_start_window_seconds()reads the variable;_start_window_attempts()convertsit to polls of
_START_WINDOW_POLL_SECONDS(0.3 s)._await_daemon_ready'sattemptsbecomesint | None = None, meaning "use thehost's window". A caller that passes a number still overrides it — which is what
keeps the existing tests deterministic and independent of the machine's
environment.
_truncate_start_stderralready follows: a diagnostic must never be able to makea start fail, and a typo in a tuning variable would be exactly that. It logs one
warning. Two float-shaped values are rejected anyway, because "float-shaped" is not
the same test as "a duration": a non-positive number (
0,-5) would produce aloop that never waits and then reports a start it never waited for, and a
non-finite one (
inf,nan) raises inside the seconds→polls arithmetic(
round(inf / 0.3)→OverflowError). Both were found by writing the test first.Whitespace-only counts as unset, since that is how a shell spells it.
Two facts make the window cheap to raise, and the docs say so: a child that has
exited is reported on the first poll with its exit code, so the window only ever
bounds a child that is alive but not yet listening; and the failure message reports
the bound the loop really waited, not the one requested, because the window is
quantised to the poll interval.
Host symmetry
EMRG_START_TIMEOUTis documented inDEVELOPMENT.mdunder Troubleshooting("
emrgd failed to start within N s") — the same reasoning as theEMRG_TASK_DIRTY_OVERRIDEentry (rant 2026-09-14T20:29:21): an escape hatch whoseeffect is "you asked for X and got Y" has to be findable without reading the source.
The section also says where the evidence for a failed attempt lives
(
~/.emrg/emrgd-start.err, the log delta, the child's exit code).Verification
tests/test_daemon_start_diagnostics.py+9:behaviour change for a host who sets nothing;
EMRG_START_TIMEOUT=1.2reaches the wait: driven end to end through_await_daemon_readywith a never-ready, never-exiting child, the failure sayswithin 1.2sand the loop polls 4 times, not 15. A value that is read but neverused is the defect this issue is about, so the test watches the wait, not the
variable;
soon/-5/0/nan/infall fall back to the default;Mutation arm — the resolver made to ignore the environment
(
raw = ""): 2 failed (test_the_window_is_what_the_host_asked_for,test_a_window_too_short_for_one_poll_still_polls_once), restored → 38 passed. Theparametrised fallback test correctly survives that arm, because it pins the
fallback; the reading is pinned by those two.
from emrg.client.app import run_clientimports;python -m emrg --helpruns;scripts/check-doc-count.py→ OK.with a stub child and a stub probe, per MANIFESTO 第四条附则二.
Refs #1276.