Skip to content

fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467, MUL-5630) - #6275

Open
GODVvVZzz wants to merge 3 commits into
multica-ai:mainfrom
GODVvVZzz:fix/openclaw-cli-process-tree
Open

fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467, MUL-5630)#6275
GODVvVZzz wants to merge 3 commits into
multica-ai:mainfrom
GODVvVZzz:fix/openclaw-cli-process-tree

Conversation

@GODVvVZzz

Copy link
Copy Markdown

What does this PR do?

It closes the gap openclawCLITimeout has documented since #6084, and fixes a
second OpenClaw misbehaviour discovered alongside it.

Bug 1 — the 5s deadline was decorative. exec.CommandContext kills only the
direct child, and cmd.Output() blocks in Wait() until the output pipes
os/exec manages reach EOF. Cancelling the context does not unblock that
io.Copy. So when openclaw leaves a descendant holding stdout — its
openclaw-config helper, or cmd.exe → node on Windows — the call runs for the
descendant's lifetime.

#6084 measured exactly this (a shim whose backgrounded child slept 6s took 6.01s
against a 150ms deadline), tried a cmd.WaitDelay backstop, and reverted it on
review because it bounds the call by leaving the descendant running — trading a
hang for a leak nothing on Unix reaps. The note it left behind names the right
fix, which is what this PR implements:

Closing this properly needs process-tree ownership (Unix process group,
Windows Job Object) so the deadline can terminate the whole tree […]
Tracked in MUL-5467

Three call sites had the shape. One is on the task's critical path
(PrepareprepareOpenclawConfig), one runs for every provider inside the
daemon's blocking registration preflight, one makes four invocations per call:

Call site Exposure
execenv.execOpenclawCLI task setup — a task can be claimed and then never start
agent.detectCLIVersion registration preflight for every registered provider
agent.discoverOpenclawAgents up to 4 invocations, so up to 4 orphans per call

Bug 2 — the answer was already on stdout. Measured on an OpenClaw host
running openclaw 2026.5.27:

openclaw --version    258ms  exits cleanly
openclaw config file    60s  correct path printed, then never exits
openclaw agents list    60s  correct list printed, then never exits

Waiting for exit turns two working commands into a task-fatal error:

agent_error.process_failure (prepare execution environment: execenv:
prepare openclaw config: locate openclaw active config:
openclaw config file: context deadline exceeded (process: signal: killed))

The contract of those commands is "print a value". Once the value has arrived
and nothing more is coming, whether the process tidies itself up is not the
caller's business, and it should not fail a chat task.

Why this approach

  • Process-tree ownership, not WaitDelay. WaitDelay was already tried and
    reverted upstream. Beyond the leak, it reports exec.ErrWaitDelay, which turns
    "the CLI printed its answer and a helper lingered" into a failed probe — and
    a failed --version probe skips runtime registration entirely, so the cure is
    worse than the disease. Owning the pipes reports the CLI's real exit status,
    which openclawShimDiagnostic also depends on (it type-switches on
    *exec.ExitError).
  • Reuses the existing primitives. configureProcessGroup /
    signalProcessGroup already exist and are used by the codex, claude, opencode
    and deveco paths. No new platform-specific functions were added.
  • There is precedent for treating output as the boundary. cursor.go already
    treats a terminal result as a protocol boundary for a CLI that keeps its worker
    alive.

Related Issue

Implements the process-tree ownership that #6084 identified as the proper fix and
tracked as MUL-5467. Relates to #3853 (OpenClaw runtime fails in execenv prep) and
#3403 (agent status stuck at working).

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Refactor / code improvement (no behavior change)
  • Documentation update
  • Tests (adding or improving test coverage)
  • CI / infrastructure

Changes Made

New helpers:

  • server/pkg/agent/run_collect.goRunCollect hands os/exec an *os.File
    instead of a buffer, so os/exec starts no copy goroutine and Wait returns the
    instant the direct child exits. reapProcessTree then signals the child's
    process group, reaping the helper and releasing the last write end so our own
    readers see EOF.
  • server/pkg/agent/run_collect_quiet.goRunCollectQuiet returns when the
    process exits, or when stdout has produced output that then stayed idle for
    400ms, whichever is first. quiet=true marks the idle path so callers can log
    the CLI's failure to exit without failing on it.

Call sites:

  • server/internal/daemon/execenv/openclaw_config.goexecOpenclawCLI now
    goes through RunCollectQuiet. Its shim diagnostics and context-attribution
    order are unchanged byte-for-byte; only the exec mechanism differs. The
    openclawCLITimeout doc is rewritten since the gap it described is closed.
  • server/pkg/agent/claude.godetectCLIVersion uses RunCollect; the
    reverted-elsewhere cmd.WaitDelay = 2s backstop is removed.
  • server/pkg/agent/models.godiscoverOpenclawAgents uses
    RunCollectQuiet, keeping its existing 30s function-level budget rather than
    gaining a per-attempt one.

Tests: run_collect_test.go (6), run_collect_quiet_test.go (4),
internal/daemon/execenv/openclaw_process_tree_test.go (3).

Deliberate non-goals

  • Windows descendants. There is no process group to signal there, so calls
    become bounded (by the drain grace) but a descendant can still outlive one.
    That needs a Job Object and is the remaining half of MUL-5467; the code says so
    rather than implying otherwise.
  • The fail-closed contract stays. The idle shortcut cannot mask a silent CLI:
    with no output there is nothing to salvage, so the deadline still governs and
    the call still fails. A non-zero exit still fails too, with stderr intact.
    TestPrepareOpenclawConfigFailsClosedOnCLIError still passes.

How to Test

  1. cd server && go test ./pkg/agent/... ./internal/daemon/execenv/... -race
    — both packages pass. All 51 existing Openclaw* tests in execenv still pass,
    including TestExecOpenclawCLITimeoutIsNotMisdiagnosedAsMissingInterpreter
    and TestExecOpenclawCLICancellationIsWrapped (their shims emit no stdout, so
    the salvage path is unreachable and the
    errors.Is(err, context.DeadlineExceeded) contract is preserved).

  2. Mutation-check bug 1: in run_collect.go replace the *os.File pipes with
    bytes.Buffer and delete the reapProcessTree call, then
    go test ./pkg/agent/ -run TestRunCollectReturnsDespitePipeHoldingGrandchild -timeout 40s.
    It hangs, and the dump is the production stack:

    panic: test timed out after 40s
    os/exec.(*Cmd).awaitGoroutines → os/exec.(*Cmd).Wait     ← blocked
    io.Copy(...) → os/exec.(*Cmd).writerDescriptor.func1     ← parked here
    
  3. Mutation-check bug 2: disable the idle branch in run_collect_quiet.go, then
    run TestRunCollectQuietReturnsOnceOutputGoesIdle and
    TestExecOpenclawCLIToleratesNonExitingCLI. Both fail after a full 60s
    (took 1m0.001s — waited for an exit that never comes).

  4. Flake check: 6 consecutive runs of both packages, no failures. A clean
    origin/main worktree was run 3× as a baseline for comparison.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — N/A, daemon-only
  • I have updated relevant documentation to reflect my changes (the
    openclawCLITimeout doc comment that described this as an open gap)
  • If I added a new runtime / coding tool / UI tab, I synced landing copy and docs — N/A
  • If this PR touches Chinese product copy — N/A
  • I have considered and documented any risks above
  • I will address all reviewer comments before requesting merge

Risks

  • The 400ms idle grace assumes these commands emit their response in
    back-to-back writes, which is what was measured. A CLI that paused mid-response
    for longer than that would be cut short — which is why the helper is scoped to
    one-shot commands only and the doc comment says so. Agent execution, where a
    pause is meaningful, is untouched.
  • reapProcessTree issues the group kill just after Wait reaped the leader, so
    in principle the leader's pid is already free for reuse. Sequential pid
    allocation makes reuse in that window not a practical concern, and it is the
    same window the other backends' cancellation paths already live with. Noted in
    the code rather than left implicit.

AI Disclosure

AI tool used: Kiro CLI

Prompt / approach: Both bugs were first diagnosed and fixed on a private
branch while running the daemon against a host with openclaw 2026.5.27, which is
where the timings and the error strings in this description come from. For this PR
the fix was re-derived against current main rather than cherry-picked: that
branch predates several relevant changes here, and notably main has since grown
its own process-group helpers, so a straight port would have introduced a parallel
set of them. The AI-assisted loop was: fetch each affected file from main and
confirm the bug is still present, read the surrounding commit history to find that
#6084 had already diagnosed bug 1 and chosen to leave it open, implement on top of
the existing primitives, then mutation-verify every new test by reverting the
mechanism it guards and confirming the test fails for the stated reason.


Two things I am happy to change if you would rather have them differently, since
both are judgement calls rather than requirements of the fix:

  • RunCollect / RunCollectQuiet live in pkg/agent because that is where the
    process-group primitives already are, but this makes internal/daemon/execenv
    depend on pkg/agent in production code for the first time. I can move them to
    a neutral package instead.
  • The two bugs share one mechanism and one set of call sites, so they are in one
    PR. Given fix(daemon): diagnose silent OpenClaw npm shim failures on Windows (MUL-5422) #6084 was deliberately kept narrow, say the word and I will split this
    into "process-tree ownership" and "tolerate a CLI that prints then hangs".

…orceable (MUL-5467)

openclawCLITimeout has carried a "Known gap (deliberately not fixed here)"
note since multica-ai#6084: the 5s deadline could not actually bound an
`openclaw config ...` invocation. This closes it, and fixes a second
OpenClaw misbehaviour found alongside it.

## Bug 1 — the deadline was decorative

exec.CommandContext kills only the direct child. cmd.Output() blocks in
Wait() until the output pipes os/exec manages reach EOF, and cancelling the
context does not unblock that io.Copy. So when openclaw leaves a descendant
holding stdout — its `openclaw-config` helper, or cmd.exe → node on Windows
— the call runs for the *descendant's* lifetime.

multica-ai#6084 measured this on linux/dash (a shim whose backgrounded child slept 6s
took 6.01s against a 150ms deadline), tried a cmd.WaitDelay backstop, and
reverted it on review because it bounds the call by leaving the descendant
running — trading a hang for a process leak that nothing on Unix reaps. The
note left behind names the real fix: process-tree ownership.

Three call sites had the shape:

  - execenv.execOpenclawCLI       on the task's critical path
                                  (Prepare → prepareOpenclawConfig)
  - agent.detectCLIVersion        runs for every provider inside the
                                  daemon's blocking registration preflight
  - agent.discoverOpenclawAgents  four invocations per call

## Bug 2 — the answer was already on stdout

Measured on an OpenClaw host (openclaw 2026.5.27):

  openclaw --version    258ms  exits cleanly
  openclaw config file    60s  correct path printed, then never exits
  openclaw agents list    60s  correct list printed, then never exits

Waiting for exit turns two working commands into a task-fatal error:

  agent_error.process_failure (prepare execution environment: execenv:
  prepare openclaw config: locate openclaw active config:
  openclaw config file: context deadline exceeded (process: signal: killed))

The contract of these commands is "print a value". Once the value has
arrived and nothing more is coming, whether the process tidies itself up is
not the caller's business, and it should not fail a chat task.

## The fix

Two helpers in server/pkg/agent, both built on the process-group primitives
codex/claude/opencode already use — no new platform-specific functions:

  - RunCollect: hands os/exec an *os.File instead of a buffer, so os/exec
    starts no copy goroutine and Wait returns the instant the direct child
    exits. Then signals the child's process group, which reaps the helper
    and releases the last write end, so our own readers see EOF. Unlike
    WaitDelay this reports the CLI's real exit status, which matters because
    a failed `--version` probe skips runtime registration entirely, and
    because openclawShimDiagnostic type-switches on *exec.ExitError.
  - RunCollectQuiet: returns when the process exits, or when stdout has
    produced output that then stayed idle for 400ms — whichever comes first.
    Reports quiet=true on the idle path so callers can log the CLI's failure
    to exit without failing on it.

The idle shortcut deliberately cannot mask a silent CLI: with no output
there is nothing to salvage, so the deadline still governs and the call
still fails. A non-zero exit still fails too, with stderr intact.

execOpenclawCLI keeps its shim diagnostics and its context-attribution
order byte-for-byte; only the exec mechanism changes.
discoverOpenclawAgents keeps its existing 30s function-level budget rather
than gaining a per-attempt one.

Windows: there is no process group to signal, so calls are bounded (by the
drain grace) but a descendant can still outlive one. Owning descendants
there needs a Job Object and is not attempted here.

## Verification

  - Both packages green with -race; 6 consecutive runs, no flakes.
  - All 51 existing Openclaw* tests in execenv pass, including
    TestExecOpenclawCLITimeoutIsNotMisdiagnosedAsMissingInterpreter and
    TestExecOpenclawCLICancellationIsWrapped — their shims produce no
    stdout, so the salvage path is not reachable and the
    errors.Is(err, context.DeadlineExceeded) contract is preserved.
  - 13 new tests. Both mechanisms are mutation-verified:
      * RunCollect reverted to buffers with no group reap → the guard test
        hangs and the dump is the production stack: cmd.Wait →
        awaitGoroutines blocked, writerDescriptor parked in io.Copy.
      * RunCollectQuiet's idle path disabled → both the unit test and the
        execenv end-to-end test fail after a full 60s.
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the IndexLabs Team on Vercel.

A member of the Team first needs to authorize it.

@multica-eve multica-eve changed the title fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467) fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467, MUL-5630) Aug 3, 2026

@multica-eve multica-eve left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个改动希望让用户在准备本地工作环境时,不再因为辅助工具已经给出答案却迟迟不结束而一直等待,也避免反复使用后留下无用的后台工作。产品方向应该接受,但当前版本仍可能把不完整答案当成成功,因此暂不能合入。

请求修改以下阻塞项:

  1. server/pkg/agent/run_collect_quiet.go:139-145ctx.Done() 后只要 stdout 非空就返回成功,既没有等待完整的 idleGrace,也没有验证路径/JSON 已完整。持续输出到 deadline 的半段结果因此会被 salvage;server/pkg/agent/run_collect_quiet.go:164-165 还丢弃了 finish() 的错误。我的最小复现让后代持续写入到 250ms deadline,10 次中有 9 次被误报成功。请取消 deadline 分支的无条件 salvage,改由调用方用命令专属的完整性规则确认输出,并补“持续输出到超时”“提示后延迟输出正文”“延迟非零退出”的回归测试。

  2. quietWriter.Writeserver/pkg/agent/run_collect_quiet.go:30-36 先解锁 buffer、再更新 lastByte。ticker 可能在这个窗口看到新字节配旧时间戳,从而立即误判输出已经安静并截断后续内容。请把 buffer 写入和最后写入时间放在同一临界区内更新。

  3. server/pkg/agent/run_collect.go:120-130 在 drain grace 超时后直接读取并返回 bytes.Buffer,此时 io.Copy 仍可能并发写入;defer 关闭读端发生在返回值求值之后,存在数据竞争。超时路径应先关闭读端、等待两个读取协程结束,再读取 buffer。RunCollectQuiet 在 Windows 上也会在后代仍持有管道时留下被 cmd.Wait/复制阻塞的 goroutine;即使 Job Object 留待后续,本 PR 也应保证函数返回前本地管道和 Go goroutine 已可靠收束,并增加 Windows 契约覆盖。

已验证现有 -race 定向测试、ByteIdentical 测试、go vetgit diff --check 均通过;上述反例目前不在测试覆盖中。修正这些边界后,这个 EOF/进程组方案可以继续 review。

Three blocking items from review of multica-ai#6275. All three were reproduced before
being fixed, and the first one was a genuine correctness hole rather than a
style objection.

## 1. The deadline branch salvaged partial answers

RunCollectQuiet returned success from the ctx.Done() branch whenever stdout was
non-empty, without requiring the idle grace or any check that the answer was
finished. A CLI still streaming when the deadline arrived therefore had its
truncated output reported as a completed response. Reproduced with a stub
emitting a JSON document forever against a 250ms deadline: 9 runs in 10 came
back as success carrying an unparseable fragment.

The early return now needs two independent conditions, neither sufficient
alone:

  - an OutputComplete rule supplied by the caller accepts the buffer, and
  - the buffer has since been idle for idleGrace.

Reaching the deadline is never success. The captured bytes are still returned
alongside the error so a caller with its own rule can inspect them, but the
runner no longer decides on the caller's behalf. A nil rule disables the early
return entirely, which is the conservative default for a command whose output
shape has no completeness rule.

The rule cannot be inferred inside the runner, which is the point: only the
caller knows whether `{"agents":[{"id":"a"},` is truncated or legitimate.

  - `--json` subcommands -> agent.JSONOutputComplete (whole buffer parses).
  - `openclaw config file` -> openclawConfigPathComplete (last non-empty line
    looks like a path). Deliberately stricter than
    openclawParseActiveConfigPath, which resolves a relative line through
    filepath.Abs and would therefore accept a Doctor warning border as an
    answer. That matters because OpenClaw prints Doctor UI *before* the path
    (MUL-3136), so an early return on idle output alone hands back the banner
    as the config path — and nothing downstream catches it.
  - `agents list` text fallback -> RunCollect (wait for exit). Decorated text
    has no completeness rule and a silently short agent catalog is worse than a
    slow one; this path still gains the reaping and the bounded return.

## 2. quietWriter published bytes before their timestamp

The buffer write and the last-write timestamp were updated in separate critical
sections, so a reader could observe new bytes carrying a stale timestamp and
conclude the stream had gone quiet at the moment it was producing — truncating
an answer mid-write. Both now move in one critical section (outputBuffer).

## 3. The drain-timeout path raced the readers, and could park a goroutine

RunCollect read its buffers after the drain grace expired while io.Copy could
still be appending to them; the deferred Close on the read ends ran only after
the return values had been evaluated. Separately, RunCollectQuiet handed os/exec
io.Writers, so its cmd.Wait goroutine could sit forever on a descendant holding
the pipe — on Windows nothing released it, since there is no process group to
signal.

Both are now structural rather than patched. A single collector owns the pipes
for both helpers (the *os.File ownership RunCollect already had), and finish()
guarantees that when it returns, every goroutine startCollector spawned has been
joined and nothing can still append to the buffers: it reaps the tree, waits
bounded for Wait, and if the readers still have not seen EOF it closes the read
ends outright — which makes the in-flight Read return — and only then joins them.

## Verification

  - Both packages pass with -race under CI's flags (-p 2 -parallel 2).
  - All pre-existing Openclaw* tests still pass, including the timeout
    attribution and shim diagnostic ones.
  - New regression tests, all three named in the review: continuous output until
    the deadline (must not be success), a prompt followed by a delayed body (must
    not return the prompt), and a late non-zero exit within the grace (must be
    reported as the failure it is). Plus the completeness rules per subcommand,
    the banner-then-path case end to end through execOpenclawCLI, goroutine
    collection on both helpers, and the two outputBuffer contracts.
  - Windows contract coverage added (run_collect_windows_test.go) with a scoped
    step in the existing windows-execenv CI job, since the close-the-read-ends
    path is unreachable on Unix where the group kill releases the pipe.
  - Mutation-verified: restoring the unconditional salvage fails both the unit
    and the execenv regression; dropping the completeness rule from
    execOpenclawCLI makes the Doctor banner come back as the config path.
@GODVvVZzz

Copy link
Copy Markdown
Author

Thanks — all three were real, and item 1 was a correctness hole rather than a style objection. I reproduced each one before fixing it, and your repro number for item 1 reproduced exactly: 9 runs in 10 against a 250ms deadline.

1. The deadline branch salvaged partial answers

You were right that the code conflated "we have some output" with "we have the output". The early return now needs two independent conditions, neither sufficient alone:

  • an OutputComplete rule supplied by the caller accepts the buffer, and
  • the buffer has since been idle for idleGrace.

Reaching the deadline is never success. The captured bytes are still returned alongside the error so a caller with its own rule can inspect them, but the runner no longer decides on the caller's behalf. A nil rule disables the early return entirely, so the call waits for exit — the conservative default for a command whose output shape has no completeness rule. Adding a subcommand without a rule therefore loses the hang tolerance rather than risking a truncated answer.

Per-subcommand rules (openclawOutputComplete):

subcommand rule
anything with --json agent.JSONOutputComplete — whole buffer parses; null counts, empty does not
openclaw config file openclawConfigPathComplete — last non-empty line looks like a path
agents list (text fallback in discoverOpenclawAgents) none; uses RunCollect and waits for exit

openclawConfigPathComplete is deliberately stricter than openclawParseActiveConfigPath, which resolves a relative line through filepath.Abs and would therefore accept a Doctor warning border as an answer. That leniency is fine once the command has finished, but as a completeness rule it would let the early return fire on the banner OpenClaw prints before the path.

That case is not hypothetical. Timestamped stdout from a live host running openclaw 2026.5.27:

16:32:57.195 | │                          <- first stdout byte (banner)
16:32:57.200 | ├──────...╯                <- banner ends
16:32:57.254 | ~/.openclaw/openclaw.json  <- the actual answer

Without the rule the early return hands back ├──────╯ as the config path. Mutation-verified: replacing the rule with "any non-empty output" makes TestExecOpenclawCLIWaitsForThePathAfterDoctorBanner fail with exactly that, the banner returned as the path.

The text fallback moved to RunCollect because decorated TUI text has no completeness rule, and a silently short agent catalog is worse than a slow one. It still gains the reaping and the bounded return.

New regression tests, the three you named plus the rule table:

  • TestRunCollectQuietDoesNotSalvagePartialOutputAtDeadline — continuous output until the deadline is an error, never success
  • TestRunCollectQuietWaitsForTheAnswerAfterAPrompt — a prompt followed by a delayed body must not return the prompt
  • TestRunCollectQuietReportsLateNonZeroExit — a complete answer followed by exit 5 within the grace is reported as the failure it is
  • TestOpenclawOutputCompleteRules — which rule each subcommand shape gets, and that an unknown shape gets none
  • TestExecOpenclawCLIWaitsForThePathAfterDoctorBanner / TestExecOpenclawCLIDoesNotSalvagePartialJSON — both end to end through execOpenclawCLI

On the grace being the observation window: a CLI that prints a complete answer and then exits non-zero is reported as the failure, as long as it does so within idleGrace. Beyond that it is indistinguishable from one that prints an answer and hangs forever, which is the case the helper exists to survive. That tradeoff is now stated on DefaultQuietIdleGrace.

2. quietWriter published bytes before their timestamp

Fixed by construction rather than by ordering: outputBuffer updates the buffer and the last-write timestamp inside one critical section, so a reader cannot observe new bytes carrying a stale timestamp. I am not claiming a deterministic repro for a window that narrow — the old shape used an atomic.Int64 so it was a logical race, not one -race would flag. What I added is TestOutputBufferPublishesBytesAndTimestampTogether, which hammers the invariant concurrently and runs under -race.

3. The drain-timeout path raced the readers, and could park a goroutine

Both are structural now. A single collector owns the pipes for both helpers — the *os.File ownership RunCollect already had — and finish() guarantees that when it returns, every goroutine startCollector spawned has been joined and nothing can still append to the buffers:

  1. reap the process tree,
  2. wait bounded for Wait,
  3. if the readers still have not seen EOF, close the read ends outright — which makes the in-flight Read return — and only then join them.

Step 3 is the fix for the race you found: the previous revision read the buffers while io.Copy could still be appending, because the deferred Close ran only after the return values had been evaluated. TestOutputBufferAbsorbStopsWhenReadEndClosed pins the mechanism that makes the join terminate.

Because RunCollectQuiet no longer hands os/exec an io.Writer, its cmd.Wait goroutine can no longer be held open by a descendant either. TestRunCollectLeavesNoGoroutines and TestRunCollectQuietLeavesNoGoroutines assert no goroutine outlives either call.

For the Windows half you asked about: run_collect_windows_test.go (windows-tagged) plus a scoped step in the existing windows-execenv job, following the precedent from #6084. It pins that a CLI which prints a complete answer and refuses to exit still yields that answer, that a response still streaming at the deadline is not success, and that no goroutine outlives the call. That last one is why it cannot live in the ubuntu job: on Unix the group kill releases the pipe, so the close-the-read-ends path is never taken. Owning descendants on Windows still needs a Job Object and remains out of scope; the code says so rather than implying otherwise.

Verified on a live host

End to end on a host running the exact CLI version the diagnosis was based on (openclaw 2026.5.27), against a real chat task. On that host openclaw config file is confirmed to print its answer and then never exit (rc=124 under timeout 20), and it prints the Doctor banner before the path — so both hazards this PR is about are real there.

17:00:38.292  picked chat task
17:00:41.917  execenv: prepared env                      <- 3.625s
17:00:42.139  openclaw started pid=9705
17:00:58.979  LLM call finished (11.5s)
17:01:01.641  openclaw finished status=completed
17:01:01.739  task completed status=completed

The 3.625s prep window matches the sum of the two openclaw CLI calls measured on that host: config file at ~2.76s (2.31s to the first stdout byte, 0.06s of output, then the 400ms grace) plus config get agents.list --json at 0.861s. No task-prep errors, openclaw-config orphan count unchanged at 0 before and after, execution slot released.

Two incidental findings from that host, neither changing this PR:

  • openclaw config get --json produces zero bytes on stdout and never exits (rc=124 at 25s), while config get agents.list --json exits rc=1 in 861ms with the expected Config path not found: agents.list wording. Since openclawResolvedFullConfig is only reached when hasManagedMcp && exists, an agent with a managed mcp_config will now fail prep in 5s with a clear error instead of hanging — which is the intended improvement, but the underlying CLI behaviour looks like its own problem. Happy to file a separate issue with the measurements if useful.
  • openclaw config file collapses the $HOME prefix when printing, so the same file was reported as ~/.openclaw/openclaw.json under one HOME and /root/.openclaw/openclaw.json under another. The daemon normally shares openclaw's HOME, making the tilde form the common one — which is why openclawConfigPathComplete accepts it and not just filepath.IsAbs. Absolute-path fixtures alone would not have caught that, so the test case now records where the two forms came from.

Verification summary

  • Both packages pass with -race under CI's flags (-p 2 -parallel 2).
  • All pre-existing Openclaw* tests still pass, including the timeout-attribution and shim-diagnostic ones — their shims emit no stdout, so the early return is unreachable and the errors.Is(err, context.DeadlineExceeded) contract is preserved.
  • GOOS=windows build, vet and test compilation are clean.
  • Mutation-verified both ways: restoring the unconditional salvage fails the unit and the execenv regression; dropping the completeness rule from execOpenclawCLI brings the Doctor banner back as the config path.

The two execenv fixtures printed the literal path the host had printed,
`/root/.openclaw/openclaw.json`. openclawParseActiveConfigPath stats what it
parses and tolerates only os.ErrNotExist, so that path resolves differently by
environment: ENOENT on a developer machine, where /root does not exist, but
EACCES on a Linux CI runner, where it exists and is unreadable. The second case
is a hard error, so TestExecOpenclawCLIWaitsForThePathAfterDoctorBanner failed
in CI for a reason unrelated to the boundary it covers.

Both fixtures now print a real file inside t.TempDir(), which stats cleanly
everywhere. The second test only inspects raw stdout today, but a hard-coded
path there would be a trap for whoever next adds a parse to it.

The literal path stays in the completeness-rule table, which calls the
predicate directly and never stats, so it keeps documenting the shape the CLI
actually emits.
@GODVvVZzz

Copy link
Copy Markdown
Author

CI caught a fixture bug of mine — pushed 68a3ce39a to fix it. Recording it here since it is instructive about the same hazard this PR is about.

Two of the new execenv fixtures printed the literal path the host had printed, /root/.openclaw/openclaw.json. openclawParseActiveConfigPath stats what it parses and tolerates only os.ErrNotExist, so that path resolves differently by environment:

  • developer machine: /root does not exist → ENOENT → tolerated → green
  • Linux CI runner: /root exists and is unreadable → EACCES → hard error → red
--- FAIL: TestExecOpenclawCLIWaitsForThePathAfterDoctorBanner (1.45s)
    stat openclaw config /root/.openclaw/openclaw.json: permission denied

Reproduced the mechanism locally before fixing, by pointing the fixture at a chmod 000 directory to get EACCES on a machine where /root yields ENOENT — same error, same line. Both fixtures now use a real file inside t.TempDir(), which stats cleanly everywhere. The literal path stays only in the completeness-rule table, which calls the predicate directly and never stats, so it keeps documenting the shape the CLI actually emits.

Green locally under CI's own parameters, go test -race -p 2 -parallel 2 -count=1 over both packages, which is stricter than CI since scripts/test-go.sh deliberately runs pkg/agent on its own.

One note on the failed run for anyone reading its log: pkg/agent produced no result line there. It was not skipped by configuration — scripts/test-go.sh invokes it as a second go test, and set -eu aborted after the first one failed. It ran and passed on the previous push of this branch.

@GODVvVZzz
GODVvVZzz requested a review from multica-eve August 3, 2026 11:00
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.

2 participants