Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ What EMRG does today — capability statements, not a changelog.
One line per command: what it answers. The *why* behind each guard is in its docstring and `--help`, not here. Run the daemon in the foreground with `python -m emrg`.

- Python: `uv run pytest tests/ -v` — the number is **measured, never stored** (a stored count is a conflict magnet that goes stale silently): `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (127: 71 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
GUI: `cd emrg/gui && npm test` (135: 79 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (516: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 19 daemonBridge + 7 DaemonBridgeProvider + 31 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown)
- CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push event with `scripts/re-trigger-ci.sh <branch>`, never an empty commit
- Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (what merging dirties) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes the guards) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3`
Expand Down
46 changes: 30 additions & 16 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,30 +454,44 @@ Check `emrg -v` and when the daemon started before re-reading the source.

### `emrgd failed to start within N s`

The client spawns the daemon and waits for it to accept connections. When that
wait runs out, the error carries what is known about the attempt: whether the
child is still running, its exit code if it is not, the daemon log lines **this**
attempt appended, and the contents of `~/.emrg/emrgd-start.err` (the child's own
stderr, which is where a failure before logging starts can be read at all).

The window defaults to **4.5 s** (15 polls, 0.3 s apart). A start that is merely
slower than that on a cold machine is worth raising it for; set the variable on
the command that starts the client, since the client is what waits:
Whichever entry point you started spawns the daemon and waits for it to accept
connections. When that wait runs out, the error carries what is known about the
attempt: whether the child is still running, its exit code if it is not, the
daemon log lines **this** attempt appended, and the contents of
`~/.emrg/emrgd-start.err` (the child's own stderr, which is where a failure
before logging starts can be read at all).

**Both entry points read the same variable**, `EMRG_START_TIMEOUT`, in seconds:
the terminal client (`emrg`, `emrg/client/daemon_manager.py`) and the GUI
(`emrg/gui/daemon_client.js`). Their defaults differ because the GUI's was
already its own — **4.5 s** (15 polls, 0.3 s apart) for the client, **5.0 s** for
the GUI — and neither changes; what the variable adds is the ability to raise
either one. Set it on a client (for the terminal) or in the environment the GUI
is launched from:

```bash
EMRG_START_TIMEOUT=30 emrg
```

It takes seconds as a number. A value that is not a positive, finite number is
ignored, with a warning in the client log — a typo in a tuning variable must
never be the reason a start fails, so it falls back to the default rather than
raising.
A value is **a plain decimal number of seconds** — digits, an optional sign, an
optional fraction and exponent (`30`, `4.5`, `.5`, `1e2`). Anything else is
ignored with a warning in that entry point's own log and the default is used: a
typo in a tuning variable must never be the reason a start fails. The refusal is
deliberate about the shapes that merely *parse*: `0x10`, `1_000` and full-width
or Arabic-Indic digits are all refused, so the same value cannot mean 16 seconds
to one entry point and 4.5 to the other (`1_000` would be a sixteen-minute wait
if it were accepted). Both entry points read one shared list of allowed values in
their tests — `tests/data/start_window_shapes.json` — so a divergence in either
resolver is a test failure rather than a surprise.

Two facts make the window cheap to raise: 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 report names the bound it really
waited, not the one you asked for, because the window is quantised to the 0.3 s
poll.
is alive but not yet listening; and the reported bound is one the loop really
applied, with a floor of one poll on both sides. The two report it differently,
and both are honest: the client quantises the window to whole polls and names
that product (`1.0` → `0.9s`), while the GUI's loop is deadline-based, names the
deadline it enforced and may therefore overshoot it by up to one poll (`1.0` →
`1.0s` reported, ~1.2 s actually waited).

### Why is a write blocked at `workspace-write`?

Expand Down
31 changes: 23 additions & 8 deletions emrg/client/daemon_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import logging
import math
import os
import re
import signal
import subprocess
import sys
Expand Down Expand Up @@ -350,6 +351,18 @@ def _startup_failure_detail(
_START_WINDOW_ENV = "EMRG_START_TIMEOUT"
_START_WINDOW_DEFAULT_SECONDS = 4.5
_START_WINDOW_POLL_SECONDS = 0.3
# The shape a duration is written in: a plain decimal number, ASCII digits only.
#
# Spelled out here rather than left to `float()`, because "float-shaped" is not the
# same test as "a duration", and because `float()`'s accepted set is *not* the one the
# GUI's `Number()` implements — so the two entry points disagreed on 7 of the 28 values
# in `tests/data/start_window_shapes.json` (measured on #1404's head: `0x10` is sixteen
# to `Number()` and a typo to `float()`; `1_000` is a thousand seconds to `float()` and
# a typo to `Number()`; `4` and `٣` are digits to `float()` and letters to `Number()`).
# Both sides now name this same shape, and both suites read that one list: a value the
# GUI refuses is a value the client refuses, or "the two read the same variable" is a
# sentence about a variable rather than about the window it is supposed to set.
_START_WINDOW_SHAPE = re.compile(r"[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?")


def _start_window_seconds() -> float:
Expand All @@ -365,23 +378,25 @@ def _start_window_seconds() -> float:
fail**, and a typo in a tuning variable would be exactly that. It is logged
once, where a host who is reading the client log can see it.

Two shapes that parse as a float are rejected anyway, because "float-shaped"
is not the same test as "a duration": a non-positive number would produce a
loop that never waits and then reports a start it never waited for, and a
non-finite one (`inf`, `nan`) raises inside the arithmetic that turns seconds
into polls. Whitespace-only counts as unset — that is how a shell spells it.
"Malformed" is decided by ``_START_WINDOW_SHAPE``, not by whether the string
parses: `1_000` parses (and would wait sixteen minutes), `4` parses (and would
wait four seconds) — both are typos, and both are refused. What is left for the
sign test below: a non-positive number would produce a loop that never waits and
then reports a start it never waited for. (`inf` / `nan` cannot reach it any
more — the shape has no spelling for them — and the ``isfinite`` check stays as
the arithmetic's own guard.) Whitespace-only counts as unset — that is how a
shell spells it.
"""
raw = (os.environ.get(_START_WINDOW_ENV) or "").strip()
if not raw:
return _START_WINDOW_DEFAULT_SECONDS
try:
seconds = float(raw)
except ValueError:
if not _START_WINDOW_SHAPE.fullmatch(raw):
logger.warning(
"%s=%r is not a number — using %.1fs",
_START_WINDOW_ENV, raw, _START_WINDOW_DEFAULT_SECONDS,
)
return _START_WINDOW_DEFAULT_SECONDS
seconds = float(raw)
if not math.isfinite(seconds) or seconds <= 0:
logger.warning(
"%s=%r is not a positive number of seconds — using %.1fs",
Expand Down
71 changes: 66 additions & 5 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,18 @@ const EMRGD_START_ERR = () => path.join(os.homedir(), ".emrg", "emrgd-start.err"
const EMRGD_PORT = 56031;
const MAX_PAYLOAD = 16 * 1024 * 1024; // G62/G105:16MB 双向一致(工具输出上限 200KB)
const AUTH_TIMEOUT_MS = 10_000;
// Issue #1276 item 5:启动等待窗口。GUI 自己的默认值保持 5.0s 不变(改默认值是一次
// 行为变更,而"冷启动是否真需要更久"没有被测量过);变的是它现在**可被宿主抬高**——
// 与 emrg/client/daemon_manager.py 的 `EMRG_START_TIMEOUT` 同一个变量、同一套回落规则
// (见 `_startWindowMs`)。此前 GUI 只有这个源码常量,而它被打包进 app.asar:最需要
// 抬高窗口的宿主(不用终端的那些人)恰恰是唯一够不着它的人。
const SPAWN_WAIT_MS = 5_000;
const SPAWN_WAIT_POLL_MS = 300;
const START_WINDOW_ENV = "EMRG_START_TIMEOUT";
// 一个"时长"写成的形态:纯十进制数,只认 ASCII 数字。与 emrg/client/daemon_manager.py
// 的 `_START_WINDOW_SHAPE` 是同一个形态(那边用 re.fullmatch,这边锚定 ^...$),
// 两侧的测试读同一份清单 `tests/data/start_window_shapes.json`。
const START_WINDOW_SHAPE = /^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$/;
const PENDING_TIMEOUT_MS = 5_000;
// Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):单个"连接生命周期"内最多 spawn
// MAX_SPAWN_ATTEMPTS 次 daemon——之后不再拉起,只把真实错误(含 emrgd.log 尾部)
Expand Down Expand Up @@ -350,14 +361,64 @@ class DaemonClient {
this._startupFailureDetail(mark, child, spawnState, stderrFile)
);
}
await new Promise((r) => setTimeout(r, 300));
await new Promise((r) => setTimeout(r, SPAWN_WAIT_POLL_MS));
}
throw new Error(
`emrgd failed to start within timeout` +
// 报的是这个循环真正守住的那个界:`deadline` 就是 `Date.now() + waitMs`,而
// `waitMs` 已被 `_startWindowMs` 抬到至少一个轮询(0.3s)——所以它是一句能为真
// 的话,且与 daemon_manager.py 的 `failed to start within {attempts * delay:.1f}s`
// 同一句法,两条入口的失败文案能被同一句话描述。
//
// 量化的部分要说清:客户端把窗口量化成 `attempts` 个轮询、报的是那个乘积;这条
// 路径守的是 deadline,**可以多等最多一个轮询**(先探测、后睡,睡完才回到条件),
// 实测 asked 1.0 → 实际 1205ms、报 1.0s。所以这个数是下界,不是一个精确值——
// 两种口径各自为真,值不必相等(同一个 1.0,客户端报 0.9s)。
`emrgd failed to start within ${(waitMs / 1000).toFixed(1)}s` +
this._startupFailureDetail(mark, child, spawnState, stderrFile)
);
}

/** 启动等待窗口(毫秒):`EMRG_START_TIMEOUT`(秒)若已设置,否则 `SPAWN_WAIT_MS`。
*
* issue #1276 item 5 的 GUI 半边。这是 emrg/client/daemon_manager.py
* `_start_window_seconds()` 的对照实现,读**同一个**环境变量、用同一套回落规则
* ——宿主给 TUI 设过的值对 GUI 同样生效,而不是两条入口各有一个只有源码能改的窗口。
*
* 取值不合法时回落到默认并告警,**绝不抛**:与 `_truncate_start_stderr` 同一条规则
* ——一个诊断/调参用的变量不得成为启动失败的原因。
*
* 形态由 `START_WINDOW_SHAPE` 判定,**不用裸 `Number()`**:`Number()` 认识
* `0x10`(宿主的笔误在 GUI 这边变成 16s 的窗口,而客户端读同一句回落 4.5s)、
* `0b101`、`0o17` 与 `Infinity`,而 Python 的 `float()` 认识 `1_000`(一千秒)与
* `4`/`٣` 这类别的数字系统。两侧各写一处同一个形态,两侧的测试读同一份清单
* (`tests/data/start_window_shapes.json`)——在一个测过的形态集合上,"两条入口读
* 同一个变量"才是关于窗口的句子,而不是关于变量名的句子。
*
* 下界是一个轮询,与客户端 `_start_window_attempts()` 的 `max(1, ...)` 相同。
* 没有它,`EMRG_START_TIMEOUT=0.01` 会真的等 ~0.3s,报告却说 "within 0.0s"
* ——那句话点名了一个它没有等过的界(实测:10ms 的窗口,实际 303ms)。
*/
_startWindowMs(env = process.env) {
const raw = String(env[START_WINDOW_ENV] ?? "").trim();
if (!raw) return SPAWN_WAIT_MS;
if (!START_WINDOW_SHAPE.test(raw)) {
this.logger.warn(
`${START_WINDOW_ENV}='${raw}' is not a number of seconds — ` +
`using ${SPAWN_WAIT_MS / 1000}s`
);
return SPAWN_WAIT_MS;
}
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
this.logger.warn(
`${START_WINDOW_ENV}='${raw}' is not a positive number of seconds — ` +
`using ${SPAWN_WAIT_MS / 1000}s`
);
return SPAWN_WAIT_MS;
}
return Math.max(seconds * 1000, SPAWN_WAIT_POLL_MS);
}

async startDaemon() {
// Rant 2026-08-09T13:16:36 ⑤:spawn 节流——超过上限不再拉起(防窗口/重试风暴)。
if (this._spawnAttempts >= MAX_SPAWN_ATTEMPTS) {
Expand Down Expand Up @@ -402,7 +463,7 @@ class DaemonClient {
// 只有真开出了诊断文件才算"读过这一路":拿不到 fd 时子进程的 stderr 退回
// "ignore",报告必须说"未捕获",而不是替这一路宣布沉默。
return await this._awaitDaemonReady(
child, mark, SPAWN_WAIT_MS, spawnState,
child, mark, this._startWindowMs(), spawnState,
errFd === null ? null : EMRGD_START_ERR()
);
}
Expand All @@ -425,10 +486,10 @@ class DaemonClient {
child.unref(); // GUI 退出不带走 daemon
this._daemonChild = child; // 暴露 child(集成测试 after 清理用)
this.logger.info(`[gui] daemon spawned: pid=${child.pid} (source mode)`); // 18:47:37 B2
// 等最多 SPAWN_WAIT_MS 就绪
// 等最多 _startWindowMs()(宿主可用 EMRG_START_TIMEOUT 抬高)就绪
const spawnState = this._watchSpawn(child);
return await this._awaitDaemonReady(
child, mark, SPAWN_WAIT_MS, spawnState,
child, mark, this._startWindowMs(), spawnState,
errFdSource === null ? null : EMRGD_START_ERR()
);
}
Expand Down
Loading
Loading