Skip to content
Open
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 docs/architecture/rfcs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/rfcs/STATUS.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | 已接受 | 无 | — |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# 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 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 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
`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 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

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 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.

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# 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 只在就绪循环
*内部*的轮询显示子进程已经退出时才读那份信封(`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'`。这个码在宿主没有于就绪
循环里观察到子进程退出时产生,而那次历史运行无法区分两种情形:预算耗尽时子进程
尚未发布,或者子进程其实已经发布并退出、而宿主在离开循环前没有观察到那次退出。
同一个 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 预算。因此修法是:在就绪预算之后给子进程一个有界的收尾
窗口,读到信封就用它,只有子进程在这个窗口内仍未结束时才报告通用超时。这样的修法
同时覆盖上面两种情形,因此它的收益不依赖于判定那次记录在案的失败究竟属于哪一种。
这个修法不在本条目里实施。

## 本条目没有确立什么

本条目不提出也不认证任何预算变更——实测表明两个预算都足够。这里也不提出属主存活性
变更:这条路径上的锁由一个存活属主持有,属主存活判定对它行为正确,而改变属主身份的
记录方式是一次带自有验收的运行时变更,本次测量并不足以支撑它。

间歇性 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`,否则输出会被吞掉。
Loading