From 4fd3bf284a99e04be93710a36af528928ff98342 Mon Sep 17 00:00:00 2001 From: xiaoan17 Date: Fri, 30 Oct 2026 13:29:33 +0800 Subject: [PATCH 1/2] docs(rfc): record the Windows startup delivery window The Windows lane fails intermittently on operations that start the Effect runtime and wait for it to become ready. Measuring the startup cost on the Windows runner shows node cold start at 0.406 s against a 15 s ready budget, and the live-lock case returning the exact mutation_lock_timeout code on that same run. That measurement refutes the earlier conclusion that node cold start plus the 5 s lock wait exceeds the ready budget on Windows. Neither budget should be widened. The failing run's own job log is retrievable and shows runtime_startup_timeout, which is produced only when the ready budget expires while the child still runs; that step ran 439 cases in 5 min 31 s and the chunk holding the failure took two minutes against six seconds for the chunk before it, so the runner was loaded and the child could not finish its mutation-lock wait inside the budget. Tracing the child shows the envelope appears only at exit, about 5.8 s in, so reading the capture at the budget would find nothing. Waiting for the exit instead yields the exact code every time, and the extra wait is bounded: the child exits at 5.13-5.17 s whatever the ready budget was, because what it waits for is the mutation-lock budget. That identifies a bounded grace window as the repair, with the acceptance points it would have to satisfy. The repair itself is not made here, and the measurement that produced the figures stays reachable so they can be re-derived. Signed-off-by: xiaoan17 --- docs/architecture/rfcs/STATUS.md | 2 +- docs/architecture/rfcs/STATUS.zh-CN.md | 2 +- ...6-10-30-s2-s12-windows-startup-delivery.md | 149 ++++++++++++++++++ ...0-s2-s12-windows-startup-delivery.zh-CN.md | 111 +++++++++++++ 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md create mode 100644 docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md diff --git a/docs/architecture/rfcs/STATUS.md b/docs/architecture/rfcs/STATUS.md index f67af4274c..e98c033cd8 100644 --- a/docs/architecture/rfcs/STATUS.md +++ b/docs/architecture/rfcs/STATUS.md @@ -54,7 +54,7 @@ appendix may keep dated history, but no dated log heading may precede it. | [RFC: Live Team Workspace v0](live-team-workspace-v0.md) | Accepted | none | — | | [RFC: Long-Horizon Harness Benchmark and Research Program v0](long-horizon-harness-benchmark-research-program-v0.md) | Accepted | none | — | | [RFC: Long-Running Agent Reliability Diagnostics and Governed Delivery v0](long-running-agent-reliability-diagnostics-governed-delivery-v0.md) | Accepted | none | — | -| [LoopX Overall Roadmap v0: Product, Collaboration, Technology and Delivery](loopx-overall-roadmap-v0.md) | Accepted | none | [1 entry](ledger/loopx-overall-roadmap-v0/) | +| [LoopX Overall Roadmap v0: Product, Collaboration, Technology and Delivery](loopx-overall-roadmap-v0.md) | Accepted | none | [2 entries](ledger/loopx-overall-roadmap-v0/) | | [Manager runtime profile v0](manager-runtime-profile-v0.md) | Accepted | none | — | | [RFC: Monorepo Distribution and Small-Kernel Runtime Composition (v0)](monorepo-distribution-split-v0.md) | Accepted | none | — | | [RFC: Obelisk Session Evidence Provider v0](obelisk-session-evidence-provider-v0.md) | Accepted | none | — | diff --git a/docs/architecture/rfcs/STATUS.zh-CN.md b/docs/architecture/rfcs/STATUS.zh-CN.md index 373aa3ef3c..2944938f69 100644 --- a/docs/architecture/rfcs/STATUS.zh-CN.md +++ b/docs/architecture/rfcs/STATUS.zh-CN.md @@ -51,7 +51,7 @@ | [RFC:团队实时工作区 v0](live-team-workspace-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:长程 Harness Benchmark 与研究计划 v0](long-horizon-harness-benchmark-research-program-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:长程 Agent 可靠性诊断与治理交付 v0](long-running-agent-reliability-diagnostics-governed-delivery-v0.zh-CN.md) | 已接受 | 无 | — | -| [LoopX 整体路线总纲 v0:产品、协作、技术与交付](loopx-overall-roadmap-v0.zh-CN.md) | 已接受 | 无 | [1 条](ledger/loopx-overall-roadmap-v0/) | +| [LoopX 整体路线总纲 v0:产品、协作、技术与交付](loopx-overall-roadmap-v0.zh-CN.md) | 已接受 | 无 | [2 条](ledger/loopx-overall-roadmap-v0/) | | [Manager runtime profile v0 / 管家运行模式 v0](manager-runtime-profile-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:Monorepo 发行拆分与小内核运行时装配(v0)](monorepo-distribution-split-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:Obelisk Session Evidence Provider v0](obelisk-session-evidence-provider-v0.zh-CN.md) | 已接受 | 无 | — | diff --git a/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md new file mode 100644 index 0000000000..9167fa706e --- /dev/null +++ b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md @@ -0,0 +1,149 @@ +# S2/S12 startup diagnostic: Windows delivery window (2026-10-30) + +Measured on `main` at `d3d9ad209d`. This entry records what the +[2026-10-04 startup diagnostic +checkpoint](2026-10-04-s2-s12-startup-diagnostic.md) did not establish on +Windows, and corrects the premise that produced an earlier wrong conclusion +about the startup budgets. It changes no runtime behavior. + +## What the earlier checkpoint established, and what it left open + +That checkpoint records that when the managed Effect runtime cannot publish its +startup locator, the failure travels through the typed startup envelope and +reuses the shared filesystem/lock codes, and that a real fixture holding a +live mutation lock "fails exactly once with the exact code". Its evidence +was a real fixture; it did not qualify Windows, and it explicitly did not +claim that Windows CI passes. + +## Measured + +The Windows lane fails intermittently on operations that start the Effect +runtime and wait for it to become ready. The failing set differs per run: +`test_effect_runtime_integration`'s `live_lock` case, +`test_shadow_observation_retirement`, `test_chat_manager_inspection`. + +Across six Windows lane runs whose job logs are still retrievable, the +failing file set was `test_self_update_runtime_activation` alone (three runs), +that file together with `test_shadow_observation_retirement` (one run), that +pair together with `test_effect_runtime_integration` (one run), and +`test_chat_manager_inspection` alone (one run). + +The `live_lock` case failed on Windows at `1e3401591` with +`assert 'runtime_startup_timeout' == 'mutation_lock_timeout'`, and passed in +the later Windows runs of this entry's own measurement. It carries no platform +skip, so it does run on the Windows lane. + +The startup cost was measured on the Windows runner at `2f544f9692` and on +macOS at `d3d9ad209d`: + +| Host | Ready startup | Live-lock total | Diagnostic delivered | +| --- | --- | --- | --- | +| Windows (`nt`) | 0.406 s | 5.813 s | `mutation_lock_timeout` | +| macOS (`posix`) | 0.170 s | 5.140 s | `mutation_lock_timeout` | + +The two revisions differ in `effect_runtime.py`, but only in the runtime-source +metadata read, `_control_plane_root` and `_request_with_info`. The startup +path itself — `_start_runtime`, `STARTUP_READY_TIMEOUT_SECONDS`, +`_read_startup_stderr` and the `process.poll()` check — is unchanged between +them, so both hosts are measured on the same startup code. + +The live-lock total is the ready startup plus the TypeScript mutation-lock +wait, and both budgets have ample headroom: `STARTUP_READY_TIMEOUT_SECONDS` +is 15.0 and `MUTATION_LOCK_TIMEOUT_MS` is 5,000. + +**This corrects an earlier conclusion.** The hypothesis that "node cold start +plus the 5 s lock wait exceeds the 15 s ready budget on Windows" is false. The +cold start is 0.406 s, and the live-lock case returned the exact +`mutation_lock_timeout` code on that same run. Neither budget should be +widened to explain this failure. + +## Where the exact diagnostic is lost + +TypeScript computes the typed code and publishes it: +`effect_runtime_server.ts` `failStartup` writes the startup envelope to +stderr with `writeSync` and then calls `process.exit`. Python reads that +envelope only when `process.poll()` shows the child has already exited +(`effect_runtime.py`); otherwise it terminates the child at the budget and +reports the generic `runtime_startup_timeout`. + +So the exact code is produced but not delivered when the poll loop does not +observe the exit inside the budget. + +The lock in this path is **not** a stale lock. The fixture writes the +current process's own PID as the lock owner, so the owner is alive and +`reclaimStaleMutationLock` correctly refuses to reclaim it; waiting out the +5 s mutation-lock budget is the intended behaviour, on every platform. The +owner-liveness check in `effect_runtime_io.ts` records only a PID, so a +reused PID or an `EPERM` probe result would also read as alive, but that is +not what this fixture exercises and this entry does not claim it as the +cause here. + +The measured live-lock total is 5.813 s on Windows and 5.140 s on macOS +against a 15 s ready budget, and the exact code was delivered on both. So +the budget is not the constraint in the measured runs. + +The intermittent Windows failure at `1e3401591` is a different situation. Its +job log is retrievable and shows +`assert 'runtime_startup_timeout' == 'mutation_lock_timeout'`. That code is +produced only when the ready budget expires while the child is still running, +so on that run the child did not exit inside 15 s. The same job's pytest +step ran 439 cases in 5 min 31 s, and the chunk holding the failure took +two minutes against six seconds for the chunk before it, so the runner was +heavily loaded. The child waits out the mutation-lock budget +before it can publish anything, and a loaded runner pushed that past the +ready budget. + +The envelope cannot be recovered by reading the capture at the moment the +budget expires. Tracing one child process shows the ordering directly: at +one second, three seconds and five seconds the child is still running and +stderr holds zero bytes, and the envelope appears only once the child exits, +at about 5.8 s, together with exit code 2. The TypeScript side reaches +`failStartup` only after the mutation-lock budget is spent, so there is +nothing typed to read until the child exits. + +It can be recovered by letting the child finish first. Waiting for the exit +instead of terminating at the budget yields the exact code in every measured +case, and the extra wait is bounded rather than open-ended: the child's exit +lands at 5.13 s to 5.17 s regardless of whether the ready budget was 0.5 s, +1 s or 2 s, because what the child waits for is the TypeScript mutation-lock +budget. A repair would therefore give the child a bounded grace window after +the ready budget, read the envelope if it appears, and report the generic +timeout only when the child does not finish inside that window. That repair +is not made here. + +## What this entry does not establish + +No budget change is proposed or qualified; the measurements show both budgets +are sufficient. No owner-liveness change is proposed here: the lock in this +path is held by a live owner, so the owner-liveness check behaves correctly +for it, and changing how an owner identity is recorded would be a runtime +change with its own acceptance that this measurement does not justify. + +The intermittent Windows failure is now attributed to a child that did not +exit inside the ready budget on a loaded runner, and a bounded grace window +after that budget is the repair this entry identifies. The repair itself is +not made here: giving the child a grace window changes how long a failed +startup blocks its caller, and that needs its own acceptance and a caller +that tolerates the longer wait. + +Two acceptance points follow from it. The caller now waits longer, so the +grace window has to be bounded by something the repository already owns +rather than by a new constant. And the user-visible guidance changes with +the code: `loopx/cli_commands/start_goal.py` gives `runtime_startup_timeout` +its own remediation text, while `mutation_lock_timeout` falls through to the +shared default, so a caller that receives the exact code would be told to +repair or reinstall LoopX where it was previously told that startup timed +out. That difference is the intended consequence of recovering the exact +code, and it has to be read as such rather than as a regression. + +The delivered typed code is a diagnostic improvement, not a correctness +repair: the runtime still fails to publish its locator, and the caller still +fails to start. Windows CI is not qualified as passing by this measurement, +and the intermittent failure set is preserved rather than reclassified. + +The measurement itself was a temporary diagnostic, removed after the numbers +were read; it is not a qualification artifact in this repository. The commit +that produced the numbers is reachable as `codex/measurement-archive` on the +fork, so the two figures can be re-derived rather than taken on trust: it +raises a deliberate `AssertionError` carrying the measurement, because the +Windows lane runs pytest without `-s` and would otherwise capture the output. diff --git a/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md new file mode 100644 index 0000000000..7e68d9530d --- /dev/null +++ b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md @@ -0,0 +1,111 @@ +# S2/S12 启动诊断:Windows 送达窗口(2026-10-30) + +在 `main` 的 `d3d9ad209d` 上测量。本条目记录 +[2026-10-04 启动诊断检查点](2026-10-04-s2-s12-startup-diagnostic.zh-CN.md) +在 Windows 上没有确立的部分,并纠正由此产生的一个关于启动预算的错误结论。 +本条目不改变任何运行时行为。 + +## 旧检查点确立了什么,未确立什么 + +那个检查点记录:当受管 Effect runtime 无法发布其启动 locator 时,故障通过类型化启动 +信封传递并复用共享的文件系统/锁码;并记录一个持有活 mutation lock 的真实 fixture +"恰好失败一次并给出精确的码"。它的证据来自真实 fixture;它没有给 Windows 资格, +并且明确没有声称 Windows CI 通过。 + +## 实测 + +Windows lane 在"启动 Effect runtime 并等待其就绪"这类操作上间歇失败。每次 run 的失败 +集合都不同:`test_effect_runtime_integration` 的 `live_lock` 用例、 +`test_shadow_observation_retirement`、`test_chat_manager_inspection`。 + +在六次 job 日志仍可获取的 Windows lane run 中,失败文件集合分别是:只有 +`test_self_update_runtime_activation`(三次);该文件加上 +`test_shadow_observation_retirement`(一次);该两者再加上 +`test_effect_runtime_integration`(一次);只有 `test_chat_manager_inspection` +(一次)。 + +`live_lock` 用例在 Windows 的 `1e3401591` 上以 +`assert 'runtime_startup_timeout' == 'mutation_lock_timeout'` 失败过,而在本条目 +自身测量的后来几次 Windows run 中通过。它没有平台 skip,因此确实会在 Windows lane +上运行。 + +启动成本分别在 Windows runner 的 `2f544f9692` 与 macOS 的 `d3d9ad209d` 上测量: + +| 宿主 | 就绪启动 | live-lock 总计 | 送达的诊断 | +| --- | --- | --- | --- | +| Windows (`nt`) | 0.406 秒 | 5.813 秒 | `mutation_lock_timeout` | +| macOS (`posix`) | 0.170 秒 | 5.140 秒 | `mutation_lock_timeout` | + +两个 revision 的 `effect_runtime.py` 有差异,但只在运行时源码元数据读取、 +`_control_plane_root` 和 `_request_with_info`。启动路径本身——`_start_runtime`、 +`STARTUP_READY_TIMEOUT_SECONDS`、`_read_startup_stderr` 与 `process.poll()` 检查—— +在两者之间未变,因此两个宿主测的是同一份启动代码。 + +live-lock 总计等于就绪启动加上 TypeScript 的 mutation lock 等待。两个预算都有充裕余量: +`STARTUP_READY_TIMEOUT_SECONDS` 为 15.0,`MUTATION_LOCK_TIMEOUT_MS` 为 5,000。 + +**本条目纠正一个此前的结论。** "Windows 上 node 冷启动加上 5 秒锁等待会超过 15 秒就绪 +预算"这一假设是错的。冷启动为 0.406 秒,且同一次运行中 live-lock 用例返回了精确的 +`mutation_lock_timeout`。两个预算都不应为了解释这个失败而放宽。 + +## 精确诊断在哪里丢失 + +TypeScript 算出了类型化码并发布它:`effect_runtime_server.ts` 的 `failStartup` 用 +`writeSync` 把启动信封写到 stderr,然后调用 `process.exit`。Python 只在 +`process.poll()` 显示子进程已经退出时才读那份信封(`effect_runtime.py`);否则它在预算 +处终止子进程并报告通用的 `runtime_startup_timeout`。 + +因此当轮询循环没有在预算内观察到退出时,精确码已经产生却没有送达。 + +这条路径上的锁**不是**陈旧锁。fixture 把当前进程自己的 PID 写成锁属主,因此属主是 +活着的,`reclaimStaleMutationLock` 正确地拒绝回收它;等满 5 秒 mutation lock 预算 +是设计行为,在所有平台上都一样。`effect_runtime_io.ts` 的属主存活判定只记录 PID, +因此被复用的 PID 或 `EPERM` 的探测结果也会被读成存活,但 fixture 并未触及这一点, +本条目也不把它当作此处的原因。 + +实测的 live-lock 总计在 Windows 上是 5.813 秒、macOS 上是 5.140 秒,而就绪预算是 +15 秒,两个宿主都送达了精确码。因此在实测的那几次运行里,预算不是约束。 + +`1e3401591` 上那次间歇的 Windows 失败是另一种情形。它的 job 日志仍可获取,显示 +`assert 'runtime_startup_timeout' == 'mutation_lock_timeout'`。这个码只在"就绪预算 +耗尽而子进程仍在运行"时产生,因此那次运行里子进程没有在 15 秒内退出。同一个 job 的 +pytest 步骤用 5 分 31 秒跑完 439 个用例,含失败的那一段用两分钟,而前一段只用六秒,说明 +runner 负载很重。子进程必须先等完 mutation lock 预算才能发布任何东西,繁忙的 runner +把它推过了就绪预算。 + +预算耗尽的那一刻补读捕获文件拿不到信封。跟踪一个子进程直接给出了时序:在 1 秒、 +3 秒和 5 秒,子进程仍在运行且 stderr 有 0 字节;信封只在子进程退出时才出现——约 +5.8 秒,与退出码 2 同时。TypeScript 侧是在 mutation lock 预算耗尽之后才走到 +`failStartup`,因此在子进程退出之前根本没有类型化的内容可读。 + +但先让子进程结束就能拿回。改为等待退出而不是在预算处终止,在每个实测情形里都 +拿到了精确码,而且这段额外等待是有上界而非无界的:无论就绪预算是 0.5 秒、1 秒 +还是 2 秒,子进程都在 5.13 到 5.17 秒退出,因为它等的是 TypeScript 的 mutation +lock 预算。因此修法是:在就绪预算之后给子进程一个有界的收尾窗口,读到信封就用它, +只有子进程在这个窗口内仍未结束时才报告通用超时。这个修法不在本条目里实施。 + +## 本条目没有确立什么 + +本条目不提出也不认证任何预算变更——实测表明两个预算都足够。这里也不提出属主存活性 +变更:这条路径上的锁由一个存活属主持有,属主存活判定对它行为正确,而改变属主身份的 +记录方式是一次带自有验收的运行时变更,本次测量并不足以支撑它。 + +间歇性 Windows 失败现在归因于:繁忙的 runner 上子进程没能在就绪预算内退出;本条目 +指出的修法是那个预算之后的有界收尾窗口。这个修法本身不在此实施:给子进程一个收尾 +窗口会改变一次失败启动阻塞调用方多久,这需要它自己的验收,以及能容忍更长等待的调用方。 + +由此产生两个验收点。其一,调用方等待的时间变长了,因此收尾窗口必须由仓库已经拥有的 +某个量来界定,而不是引入一个新常量。其二,用户可见的指引会随诊断码一起改变: +`loopx/cli_commands/start_goal.py` 为 `runtime_startup_timeout` 准备了专门的修复提示, +而 `mutation_lock_timeout` 落到共享兜底提示上;因此收到精确码的调用方,之前被告知 +"启动超时",之后会被告知"修复或重装 LoopX"。这个差异是取回精确码的预期后果, +应当被这样理解,而不是当作回归。 + +送达的类型化码是诊断信息上的改进,不是正确性修复:runtime 仍然无法发布其 locator, +调用方仍然启动失败。本测量不构成 Windows CI 通过的资格,间歇失败集合被原样保留, +没有被重新归类。 + +测量本身是一次性诊断,读到数字后已移除;它不是留在本仓库里的资格产物。产生这两个 +数字的提交在 fork 的 `codex/measurement-archive` 分支上仍然可达,因此这两个数字可以被 +重新推导,而不必只能被采信:它用一个刻意抛出的 `AssertionError` 携带测量值,因为 +Windows lane 跑 pytest 时没有 `-s`,否则输出会被吞掉。 From 7f4edafe70aad23deeb3f2866230c89ae784e215 Mon Sep 17 00:00:00 2001 From: xiaoan17 Date: Sat, 31 Oct 2026 00:12:44 +0800 Subject: [PATCH 2/2] docs(rfc): keep the recorded startup failure's cause undetermined The review is right that the entry's attribution was exclusive where the source does not support it. The baseline reads the published envelope only when a poll *within* the ready loop reports an exit; after the loop it polls once more, but that poll only decides whether to terminate the child, and the function then reports the generic timeout without reading the capture again. So the recorded Windows failure does not distinguish a child that had not published yet from a child that had published and exited with the parent not observing it. State both possibilities, keep the attribution undetermined, and describe the loaded runner as a plausible contributor rather than a demonstrated cause. Reading the capture at the budget recovers the envelope in the second situation only, and a repair with a bounded grace window covers both, so it does not depend on knowing which one occurred. Signed-off-by: xiaoan17 --- ...6-10-30-s2-s12-windows-startup-delivery.md | 77 +++++++++++-------- ...0-s2-s12-windows-startup-delivery.zh-CN.md | 51 ++++++------ 2 files changed, 73 insertions(+), 55 deletions(-) diff --git a/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md index 9167fa706e..7220391f82 100644 --- a/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md +++ b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.md @@ -62,12 +62,14 @@ widened to explain this failure. TypeScript computes the typed code and publishes it: `effect_runtime_server.ts` `failStartup` writes the startup envelope to stderr with `writeSync` and then calls `process.exit`. Python reads that -envelope only when `process.poll()` shows the child has already exited -(`effect_runtime.py`); otherwise it terminates the child at the budget and -reports the generic `runtime_startup_timeout`. +envelope only when a poll *inside* the ready loop shows the child has already +exited (`effect_runtime.py`); after the loop it polls once more, but that +poll only decides whether to terminate the child, and the function then +reports the generic `runtime_startup_timeout` without reading the capture +again. -So the exact code is produced but not delivered when the poll loop does not -observe the exit inside the budget. +So the exact code is produced but not delivered whenever the parent does not +observe the child's exit inside the ready loop. The lock in this path is **not** a stale lock. The fixture writes the current process's own PID as the lock owner, so the owner is alive and @@ -85,31 +87,36 @@ the budget is not the constraint in the measured runs. The intermittent Windows failure at `1e3401591` is a different situation. Its job log is retrievable and shows `assert 'runtime_startup_timeout' == 'mutation_lock_timeout'`. That code is -produced only when the ready budget expires while the child is still running, -so on that run the child did not exit inside 15 s. The same job's pytest -step ran 439 cases in 5 min 31 s, and the chunk holding the failure took -two minutes against six seconds for the chunk before it, so the runner was -heavily loaded. The child waits out the mutation-lock budget -before it can publish anything, and a loaded runner pushed that past the -ready budget. - -The envelope cannot be recovered by reading the capture at the moment the -budget expires. Tracing one child process shows the ordering directly: at -one second, three seconds and five seconds the child is still running and -stderr holds zero bytes, and the envelope appears only once the child exits, -at about 5.8 s, together with exit code 2. The TypeScript side reaches -`failStartup` only after the mutation-lock budget is spent, so there is -nothing typed to read until the child exits. - -It can be recovered by letting the child finish first. Waiting for the exit -instead of terminating at the budget yields the exact code in every measured -case, and the extra wait is bounded rather than open-ended: the child's exit -lands at 5.13 s to 5.17 s regardless of whether the ready budget was 0.5 s, -1 s or 2 s, because what the child waits for is the TypeScript mutation-lock -budget. A repair would therefore give the child a bounded grace window after -the ready budget, read the envelope if it appears, and report the generic -timeout only when the child does not finish inside that window. That repair -is not made here. +produced whenever the parent does not observe the child's exit inside the +ready loop, and the historical run does not distinguish two situations: the +child had not published yet when the budget expired, or the child had +already published and exited and the parent had not observed that exit +before it left the loop. The same job's pytest step ran 439 cases in 5 min +31 s, and the chunk holding the failure took two minutes against six seconds +for the chunk before it, so the runner was heavily loaded. That load is a +plausible contributor to a late publication, not a demonstrated cause, and +this entry does not settle which situation occurred. + +Reading the capture at the moment the budget expires recovers the envelope +in the second situation only. Tracing one child process shows the ordering +directly: at one second, three seconds and five seconds the child is still +running and stderr holds zero bytes, and the envelope appears only once the +child exits, at about 5.8 s, together with exit code 2. The TypeScript side +reaches `failStartup` only after the mutation-lock budget is spent, so a child +that has not exited has published nothing; a child that has exited leaves the +envelope in the capture, and the baseline leaves it unread. + +It can be recovered by letting the child finish first, or by reading the +capture again once the child has exited. Waiting for the exit instead of +terminating at the budget yields the exact code in every measured case, and +the extra wait is bounded rather than open-ended: the child's exit lands at +5.13 s to 5.17 s regardless of whether the ready budget was 0.5 s, 1 s or 2 s, +because what the child waits for is the TypeScript mutation-lock budget. A +repair would therefore give the child a bounded grace window after the ready +budget, read the envelope if it appears, and report the generic timeout only +when the child does not finish inside that window. Such a repair covers both +situations above, so it does not depend on knowing which one the recorded +failure was. That repair is not made here. ## What this entry does not establish @@ -119,9 +126,13 @@ path is held by a live owner, so the owner-liveness check behaves correctly for it, and changing how an owner identity is recorded would be a runtime change with its own acceptance that this measurement does not justify. -The intermittent Windows failure is now attributed to a child that did not -exit inside the ready budget on a loaded runner, and a bounded grace window -after that budget is the repair this entry identifies. The repair itself is +The intermittent Windows failure is now attributed to a parent that did not +observe the child's exit inside the ready loop; the historical record does +not distinguish a child that had not published yet from a child that had +published and exited unobserved, and the heavily loaded runner is a +plausible contributor rather than a demonstrated cause. A bounded grace +window after that budget is the repair this entry identifies, and it covers +both situations, so it does not depend on knowing which one occurred. The repair itself is not made here: giving the child a grace window changes how long a failed startup blocks its caller, and that needs its own acceptance and a caller that tolerates the longer wait. diff --git a/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md index 7e68d9530d..cc11731c5d 100644 --- a/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md +++ b/docs/architecture/rfcs/ledger/loopx-overall-roadmap-v0/2026-10-30-s2-s12-windows-startup-delivery.zh-CN.md @@ -51,11 +51,12 @@ live-lock 总计等于就绪启动加上 TypeScript 的 mutation lock 等待。 ## 精确诊断在哪里丢失 TypeScript 算出了类型化码并发布它:`effect_runtime_server.ts` 的 `failStartup` 用 -`writeSync` 把启动信封写到 stderr,然后调用 `process.exit`。Python 只在 -`process.poll()` 显示子进程已经退出时才读那份信封(`effect_runtime.py`);否则它在预算 -处终止子进程并报告通用的 `runtime_startup_timeout`。 +`writeSync` 把启动信封写到 stderr,然后调用 `process.exit`。Python 只在就绪循环 +*内部*的轮询显示子进程已经退出时才读那份信封(`effect_runtime.py`);循环结束后它 +还会再轮询一次,但那次轮询只用来决定是否终止子进程,随后就报告通用的 +`runtime_startup_timeout`,不会再去读捕获文件。 -因此当轮询循环没有在预算内观察到退出时,精确码已经产生却没有送达。 +因此只要宿主没有在就绪循环里观察到子进程的退出,精确码已经产生却没有送达。 这条路径上的锁**不是**陈旧锁。fixture 把当前进程自己的 PID 写成锁属主,因此属主是 活着的,`reclaimStaleMutationLock` 正确地拒绝回收它;等满 5 秒 mutation lock 预算 @@ -67,22 +68,26 @@ TypeScript 算出了类型化码并发布它:`effect_runtime_server.ts` 的 `f 15 秒,两个宿主都送达了精确码。因此在实测的那几次运行里,预算不是约束。 `1e3401591` 上那次间歇的 Windows 失败是另一种情形。它的 job 日志仍可获取,显示 -`assert 'runtime_startup_timeout' == 'mutation_lock_timeout'`。这个码只在"就绪预算 -耗尽而子进程仍在运行"时产生,因此那次运行里子进程没有在 15 秒内退出。同一个 job 的 -pytest 步骤用 5 分 31 秒跑完 439 个用例,含失败的那一段用两分钟,而前一段只用六秒,说明 -runner 负载很重。子进程必须先等完 mutation lock 预算才能发布任何东西,繁忙的 runner -把它推过了就绪预算。 - -预算耗尽的那一刻补读捕获文件拿不到信封。跟踪一个子进程直接给出了时序:在 1 秒、 -3 秒和 5 秒,子进程仍在运行且 stderr 有 0 字节;信封只在子进程退出时才出现——约 -5.8 秒,与退出码 2 同时。TypeScript 侧是在 mutation lock 预算耗尽之后才走到 -`failStartup`,因此在子进程退出之前根本没有类型化的内容可读。 - -但先让子进程结束就能拿回。改为等待退出而不是在预算处终止,在每个实测情形里都 -拿到了精确码,而且这段额外等待是有上界而非无界的:无论就绪预算是 0.5 秒、1 秒 -还是 2 秒,子进程都在 5.13 到 5.17 秒退出,因为它等的是 TypeScript 的 mutation -lock 预算。因此修法是:在就绪预算之后给子进程一个有界的收尾窗口,读到信封就用它, -只有子进程在这个窗口内仍未结束时才报告通用超时。这个修法不在本条目里实施。 +`assert 'runtime_startup_timeout' == 'mutation_lock_timeout'`。这个码在宿主没有于就绪 +循环里观察到子进程退出时产生,而那次历史运行无法区分两种情形:预算耗尽时子进程 +尚未发布,或者子进程其实已经发布并退出、而宿主在离开循环前没有观察到那次退出。 +同一个 job 的 pytest 步骤用 5 分 31 秒跑完 439 个用例,含失败的那一段用两分钟, +而前一段只用六秒,说明 runner 负载很重。这种负载是"发布迟到"的一个合理成因,但不是 +被证明的根因,本条目不确定那次究竟属于哪一种。 + +在预算耗尽的那一刻补读捕获文件,只能在第二种情形里拿回信封。跟踪一个子进程直接给出 +了时序:在 1 秒、3 秒和 5 秒,子进程仍在运行且 stderr 有 0 字节;信封只在子进程退出 +时才出现——约 5.8 秒,与退出码 2 同时。TypeScript 侧是在 mutation lock 预算耗尽之后 +才走到 `failStartup`,因此还没退出的子进程没有发布任何东西;而已经退出的子进程会把 +信封留在捕获文件里,基线却不去读它。 + +但先让子进程结束、或在子进程退出后再读一次捕获文件,就能拿回。改为等待退出而不是 +在预算处终止,在每个实测情形里都拿到了精确码,而且这段额外等待是有上界而非无界的: +无论就绪预算是 0.5 秒、1 秒还是 2 秒,子进程都在 5.13 到 5.17 秒退出,因为它等的是 +TypeScript 的 mutation lock 预算。因此修法是:在就绪预算之后给子进程一个有界的收尾 +窗口,读到信封就用它,只有子进程在这个窗口内仍未结束时才报告通用超时。这样的修法 +同时覆盖上面两种情形,因此它的收益不依赖于判定那次记录在案的失败究竟属于哪一种。 +这个修法不在本条目里实施。 ## 本条目没有确立什么 @@ -90,8 +95,10 @@ lock 预算。因此修法是:在就绪预算之后给子进程一个有界的 变更:这条路径上的锁由一个存活属主持有,属主存活判定对它行为正确,而改变属主身份的 记录方式是一次带自有验收的运行时变更,本次测量并不足以支撑它。 -间歇性 Windows 失败现在归因于:繁忙的 runner 上子进程没能在就绪预算内退出;本条目 -指出的修法是那个预算之后的有界收尾窗口。这个修法本身不在此实施:给子进程一个收尾 +间歇性 Windows 失败现在归因于:宿主没有在就绪循环里观察到子进程的退出;历史记录 +无法区分"子进程尚未发布"与"子进程已经发布并退出但没被观察到",繁忙的 runner 只是 +一个合理成因而不是被证明的根因。本条目指出的修法是那个预算之后的有界收尾窗口, +它同时覆盖这两种情形,因此不依赖于判定那次究竟属于哪一种。这个修法本身不在此实施:给子进程一个收尾 窗口会改变一次失败启动阻塞调用方多久,这需要它自己的验收,以及能容忍更长等待的调用方。 由此产生两个验收点。其一,调用方等待的时间变长了,因此收尾窗口必须由仓库已经拥有的