Skip to content

fix(daemon): make the daemon reachable on native Windows hosts (#3291) - #3330

Merged
thymikee merged 9 commits into
mainfrom
fix/windows-daemon-start-3291
Oct 9, 2026
Merged

thymikee merged 9 commits into
mainfrom
fix/windows-daemon-start-3291

Conversation

@thymikee

@thymikee thymikee commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

On native Windows the daemon never became reachable:

  1. truncateDaemonLog no longer ftruncates an append-only handle (Windows EPERMs that). POSIX keeps the original pinned 'a' handle, byte-identical to origin/main; win32 empties the log through an 'r+' handle, append-creation confined to the missing-file path.
  2. host-process.ts gains a Windows branch at the existing host-process seam: one PowerShell CIM (Win32_Process) query answers birth, CommandLine, and liveness per identity decision. The CIM row rule is "null field prints empty, never raises"; PowerShell's console is pinned to UTF-8; failed queries stay unknown evidence, fail-closed. All POSIX probes pin /bin/ps.

Refs #3291 — the live Windows run below now exists; closure is the maintainer's call.

Validation

pnpm check:affected --run passed at head a7821ef58. Mutation evidence: dropping the reaper's command/zombie comparisons or reverting either fix fails the named regression tests.

Live Windows 11 run by @pai-scaffolde at head a7821ef58 (evidence): Windows 11 Enterprise 10.0.26200, Node 24.21.0 + Electron 44.4.2, socket + HTTP, fresh state dir: doctor --debug exit 0, zero EPERM; processStartTime = exact CIM CreationDate (202610091601014504740); daemon reused on both transports; graceful stop, pid gone. Non-ASCII paths (üïøé junction + state dir) start/reuse/stop cleanly. Baseline 0.21.12 on the same host publishes no processStartTime; stop fails.

Residual (note, not blocker): full-table/cold-boot CIM timings unmeasured (warm single-PID 324–345 ms vs the 2 s budget); PowerShell-per-poll stop cost folded into the 1.2–1.4 s stop total.

iOS Smoke 'Preflight' reds were lane flakes (#3342): pass on rerun, failed identically on PRs lacking these hunks.

Windows hosts have no `ps`, so every daemon-ownership start-time and
command read answered null and a live daemon could never prove its
lifetime (#3291). Add a Windows branch to the same host-process seam
the POSIX reads use: one PowerShell CIM query answers start time
(CreationDate), command line, and liveness per pid, with a budget that
covers PowerShell startup. A failed or unanswered query stays unknown
evidence, and a live CIM row is never a zombie because terminated
Windows processes leave the table.
…cate

On Windows a handle opened append-only ('a') rejects ftruncate with
EPERM, so daemon startup died inside truncateDaemonLog before it could
listen (#3291). Create the log with 'a', then truncate through an 'r+'
handle; a failing truncation still propagates instead of being
swallowed. The regression test pins the Windows handle rule at the fs
seam.
@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.13 MB 5.13 MB +2.5 kB
Package (unpacked) 5.13 MB 5.13 MB +2.5 kB
Package (download) 1.54 MB 1.54 MB +860 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.8 ms 27.8 ms +0.0 ms
CLI --help 83.7 ms 82.8 ms -0.9 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/host-kit/src/internal/host-process.ts
Comment thread packages/host-kit/src/internal/host-process.ts Outdated
Comment thread src/daemon-registration-owner.ts Outdated
Keeping the create-then-reopen on every host dropped the inode pin between
handles, so a rotated log turned publication into an ENOENT startup failure
or a truncate of the wrong file. POSIX keeps the original single pinned
append handle (it permits ftruncate there); only win32 opens the write
handle, directly 'r+' for an existing log, with append-creation confined to
the missing-file path. A removal inside the remaining creation race still
fails startup loudly (PR #3330 review).
The Windows identity branch made every synchronous probe pay a process-tool
startup, and per-record ownership checks issued three of them. The owned-
process reaper now answers zombie/startTime/command from one async
readProcessIdentityFacts per decision, owner-liveness classification reads
one batch snapshot instead of a state probe plus a birth probe, and the
Windows host answers the zombie question without any probe because a
terminated process leaves the CIM table rather than lingering unreaped.
Also pin PowerShell's console to UTF-8: its default OEM code page corrupted
non-ASCII CommandLine rows that identity equality later compares byte-wise
(PR #3330 review).
@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

iOS Smoke 'Preflight iOS runner through public CLI' — ruled out as caused by this diff

Evidence on the failing head 0743ddba0:

  • Failed lane: iOS run 37832908816 / job 113502536513, daemon_startup_failed, startupAttempts: 1, cleanup retired (pid 5078), daemon.log 0 bytes (downloaded from the ios-artifacts artifact — the daemon died before writing anything, i.e. before/at startup, with no EPERM).
  • Same step, same signature (0-byte log, startupAttempts: 1, cleanup retired) failed on fix/android-shutdown-ime-flush-window runs 37833483850 (19:38Z) and 37843216989 (20:56Z, after my head already existed). I verified that branch's tree: its truncateDaemonLog is the original single-handle code and its host-process.ts contains no Windows branch at all — neither hunk of this PR exists there.
  • Both branches use the same hard-coded simulator UDID 5FEB61C5-… and the same AGENT_DEVICE_STATE_DIR path on the shared macos-2 pool (two different runner images, versions 20260828.587 and 20260907.0351.1). The signature — a launched daemon that dies with an empty log inside the 15 s window while retirement cleanly reaps it — fits two CI jobs contending for one simulator/state dir, not a code path this PR touches; on POSIX this PR's truncation is byte-identical to main's, and the Windows branch is dead code there.
  • On the new head 1869aed46 the iOS Smoke lane passed (run 37843135923, 21:04–21:16Z), along with the other three Smoke lanes; full pnpm check:affected --run green at this head.

Unresolved risk stated plainly: I cannot reproduce the lane locally, so contention on the shared runner is a best-supported diagnosis, not a proven one; the android-branch lane still reproduces without this PR's code, so it stays out of scope here.

@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Lane settled: environment flake, diff explicitly ruled out.

Rerun of the exact failing job (run 37832908816 attempt 2, same head 0743ddba0) passed at the 'Preflight iOS runner through public CLI' step — the same runner class, same head, green on retry.

Cross-evidence that the step is flaking independent of any daemon change:

  • PR fix(test): make the iOS simulator smoke lane's red/green signal meaningful (#2491) #3336 (head ffd575ae7, run 37839500279/job 113524981450, 20:32Z) — diff verified test/integration-only, zero daemon/host-kit files — failed the identical step with details.kind: daemon_startup_failed.
  • fix/android-shutdown-ime-flush-window failed the same step with the same 0-byte-log signature twice (19:44Z, 21:10Z); its tree provably contains neither hunk of this PR.

The one place this diff could plausibly bite the macOS path was the 2b double-open, and it cannot: at head 1869aed46 the POSIX arm of truncateDaemonLog is byte-identical to origin/main (single pinned 'a' handle, git show origin/main:src/daemon-registration-owner.ts), and the create/reopen sequence lives entirely behind process.platform === 'win32', unreachable on macOS. Independently, both failures logged a 0-byte daemon.log, meaning the daemon died before writing anything — before truncation ever runs, which happens at registration publication after the servers are opened.

No code change this round; head remains 1869aed46 with pnpm check:affected --run green (843 files / 6995 tests) recorded above. Three cubic threads answered with fixes in 0d53b5342/1869aed46.

process-lock.test.ts crossed the 1,000-line tripwire once the snapshot-aware
fixture landed, and the ratchet refuses growth. The three owner-liveness
judgment cases (zombie reclaim, null-start fail-closed, guard-free live
judgment) mirror the classification seam, not lock mechanics, so they move —
unchanged — to process-lock-owner-liveness.test.ts with the probe fixture
they consume. Lock-mechanics cases stay. No assertions changed.
@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Coverage ratchet fixed at 59323000d. process-lock.test.ts sat at exactly 1,000 lines at the merge-base; the snapshot-aware fixture pushed it to 1,015 and the ratchet refuses growth over the tripwire. Fix per the finding and docs/agents/testing.md: the three lock-owner liveness-judgment cases — zombie reclaim, null-start fail-closed, and the guard-free live judgment — moved unchanged to packages/host-kit/src/internal/process-lock-owner-liveness.test.ts, along with the probe/zombie fixture they alone consume. After the move nothing left in process-lock.test.ts references the fixture, so the entire host-process mock block left with them; the remaining file is lock mechanics on real host facts, back to 907 lines. No assertions were added, changed, or deleted (47 tests across the two files + ratchet gate, same as before).

Verification:

  • Failing check reproduced locally first (test-file-size-ratchet.test.ts named the same finding).
  • pnpm check:affected --run on 59323000d: all runnable checks passed (843 files / 6995 tests).
  • CI on 59323000d: Coverage green; all Smoke lanes green (18 success, 1 skipped), including the iOS Smoke lane that flaked earlier.

@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

The code looks right for the native-Windows route, but the Windows part has no live proof yet, so it is not ready to merge. I reviewed 1869aed. All 19 checks pass at that commit, but no CI lane runs on Windows, so the win32 route is not exercised in CI at all.

The whole change sits on a path no test has run on real Windows: CIM identity, the PowerShell UTF-8 pin, r+ truncation, and the client reusing the registration (https://github.com/callstack/agent-device/blob/1869aed/packages/host-kit/src/internal/host-process.ts#L296). The tests stub runCmd, runCmdSync and fs with hand-written CIM rows and a hand-written EPERM. If the daemon's one self-identity read (daemon-registration-owner.ts:70) times out on a cold, Defender-scanned host, daemon.json is published without processStartTime. Then the #3291 symptom returns: the client reports not-running and every retry logs "registration busy". Please run the packed tarball of this head on native Windows 11 from a fresh state dir and post: (1) npx agent-device doctor --debug succeeds and daemon.log has no EPERM; (2) daemon.json processStartTime is a 21-digit CIM stamp equal to the daemon.lock owner startTime; (3) a second command reuses the same daemon pid, with no new spawn and no "Daemon registration busy" in daemon.log; (4) agent-device daemon stop returns an exited or retired termination; (5) the measured cold powershell.exe CIM query time next to the 2 s budget. If (5) is close to the budget, please give the daemon's self-identity read a longer budget or a retry.

Not blocking, take or leave: the process.platform === 'win32' ? 'ps' arm of HOST_PS_COMMAND is now dead because every caller branches on win32 first, and the 'R' state arm in readWindowsProcessField is unreachable after the isProcessZombie short-circuit; the two Windows truncation tests in daemon-registration-owner.test.ts repeat the same openSync/ftruncateSync spy setup and could share one helper; the longer comment blocks at host-process.ts:231, owner-identity.ts:73 and owned-process-reaper.ts:175 could shrink to one-line constraints; and on POSIX, readHostProcessIdentityObservations now runs PATH ps where it used to run /bin/ps, so it should use HOST_PS_COMMAND to keep one binary for all POSIX probes.

Is there a materially smaller design? I found none, since the platform branch sits at the owning seam and every identity consumer inherits it. The only optional split is to land the POSIX-touching reaper and classifyOwnerLiveness consolidation as its own perf change. That is not a requirement.

On the open threads: the P2 thread on the remaining synchronous PowerShell polling still applies (#3330 (comment)), and its latency belongs in the evidence above. The two P3 threads are fixed at this head and can be resolved: the UTF-8 pin is in place (#3330 (comment)), and the POSIX path keeps the single a handle while Windows opens r+ directly (#3330 (comment)). A real non-ASCII round-trip is still unverified on Windows.

I could not read the test move or ratchet fix the PR body cites at 5932300, because that commit is not in my checkout. I did not run the mutation checks it claims. No Windows host was available to me, so PowerShell output format, CIM stability, truncation with a held append handle, and cold-start latency are all unverified. The claim that Windows needs no zombie probe rests on libuv's uv_kill(0) behavior, not on a live observation. Before merge, the live Windows 11 run above must show that doctor succeeds, the daemon is reused, daemon stop retires it, and cold CIM latency is under 2 s.

The head moved to 5932300 after this review. That commit only moves the lock-owner liveness tests back into process-lock.test.ts, so it does not change the points above.

…SIX probe

The snapshot predated this PR on PATH 'ps' while HOST_PS_COMMAND pinned the
per-field probes to /bin/ps. One binary for every POSIX process-table read
restores the trust posture HOST_PS_COMMAND exists for, and its dead win32
arm goes: every Windows caller branches to the CIM query first. Snapshot
and facts tests now pin the binary the probe asks for.
The EPERM-on-append-handle emulation was copied verbatim into the existing
and missing log cases. The flags it records still differ per case, so the
helper returns them and each test keeps pinning its own handle sequence.
@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Windows live verification: not executed here — the gap I am knowingly leaving open

No native Windows host is reachable from this environment, no agent in this fleet drives Windows, and no CI lane runs on Windows (.github/workflows/ has workflows for ci/android/ios/macos/linux/conformance/mutation/perf/release/replays/size/xctest — none Windows; also verified independently by the coordinator). This host has neither powershell.exe nor pwsh, so item (5) cannot even be approximated: all five items are unverified as live evidence. I am not simulating a run and not narrowing the claim.

What I did verify, at the seam the platform branch sits on:

  • CIM identity — host-process-windows.test.ts answers runCmd/runCmdSync with CIM rows shaped exactly as parseWindowsProcessRows receives them (pid|ppid|creation|command) and pins: the Get-CimInstance -Filter 'ProcessId=…' construction, culture-invariant UTC birth stamps, budget floor (timeoutMs ≥ 2_000), failed query → unknown evidence (callers stay fail-closed), listHostProcesses mapping, and "no zombie probe on Windows" (zero process-table probes, isProcessZombie → false).
  • UTF-8 pin — every emitted query string is asserted to carry the [Console]::OutputEncoding=UTF8 prefix (r4223364366, confirmed in-thread below). No row fixture carries non-ASCII text, so the decode round-trip itself is unverified at every seam, not just live.
  • r+ truncation — the Windows EPERM-on-append-handle rule is emulated at the fs seam by descriptor-flag, and the tests pin the exact handle sequence: existing log → ['r+'] only; missing log → ['r+','a','r+']; removed-in-race → loud ENOENT, never a silently unemptied log. POSIX keeps the single pinned 'a' handle — that path is byte-identical to origin/main.
  • Registration reuse / stop — lock-classification and reaper suites consume the same CIM fixtures at the same seams (readHostProcessIdentityObservations, readProcessIdentityFacts).
  • Gate: pnpm check:affected --run green at 59323000d (843 files / 6995 tests); CI 18/18 non-skipped checks green there, including Coverage.

On (5), the measured latency: unmeasurable here, stated plainly. What I can say about the shape: the budget is already max(callerBudget, 2_000) for every Windows read, and every failure path is fail-closed — a timed-out self-identity read publishes without processStartTime, and consumers then refuse to reclaim or signal. That degrades to the pre-#3291 client-reported symptoms, not to wrong-process signaling. Whether cold Defender-scanned powershell.exe startup fits inside 2 s is exactly the fact only a Windows 11 box can supply, and I am leaving that as an open, named risk rather than guessing it shut.

Residual risks I am knowingly shipping (all fail-closed, none signal a possibly-recycled pid):

  1. Cold CIM latency vs the 2 s budget — if it overruns on a Defender-scanned host, the daemon publishes without a birth time and the client again reports not-running / registration busy. Needs one live doctor --debug run to close.
  2. Non-ASCII CommandLine through the UTF-8 pin — the pin itself is asserted in every emitted query, but no fixture carries non-ASCII text, so the round-trip is untested even at the mock seam; a live C:\Users\José\… verification is owed.
  3. The remaining synchronous PowerShell polling (reply in r4223364359): stopDaemonProcess's pre-signal identity verification (2 CIM spawns) and waitForDaemonExit's per-poll snapshot — each poll can block the calling loop up to 2 s in the worst case. Latency measurement belongs to the same live run.
  4. The Windows zombie-absence claim rests on documented CIM table semantics (Win32_Process reflects live processes; terminated processes leave the table), not a live observation.

Correcting one premise in the review: 59323000d moves the liveness cases out of process-lock.test.ts, not back into it. The commit deletes 109 lines from process-lock.test.ts and adds process-lock-owner-liveness.test.ts (128 lines) in the same change — git show --stat 59323000d. File evidence at that head:

$ git show 59323000d:packages/host-kit/src/internal/process-lock.test.ts | wc -l
906
$ git show 59323000d:packages/host-kit/src/internal/process-lock-owner-liveness.test.ts | wc -l
128

git ls-tree 59323000d -- packages/host-kit/src/internal/ lists both blobs; test totals are 41 + 3 (+ 3 ratchet) = 47, matching main's 44 once the loop-generated cases are accounted (UNINFORMATIVE_OWNER_RECORDS → 7, releaseFails → 2). The Coverage lane went red→green between 1869aed and 59323000d, which only fits the out-of direction. If the commit is missing from your checkout, it needs a git fetch, not a different reading.

Also landed at head bad805c21, from the reviewer's non-blocking list (the one that was real behaviour, not cleanup): on main the identity snapshot ran PATH ps while per-field probes were pinned to /bin/ps — a split trust posture predating this PR. 1d3d2b0a0 converges the snapshot onto HOST_PS_COMMAND = '/bin/ps' and removes the dead win32 ? 'ps' arm, with tests pinning the binary. The duplicated openSync/ftruncateSync spy in the two Windows truncation tests is factored into one helper in bad805c21 (assertions unchanged: each test still pins its own handle sequence). The comment blocks at host-process.ts:231, owner-identity.ts:73, owned-process-reaper.ts:175 stay as they are — they encode the cross-language invariants the golden fixtures enforce, which is the one comment class AGENTS.md reserves.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Nothing in this PR proves the #3291 route on a real Windows host, so I don't think it is ready to close that issue. At bad805c, all Windows coverage feeds hand-written CIM rows at the exec seam and emulates the NTFS EPERM rule at the fs seam (host-process.ts#L266). If the real PowerShell output, the 2 s timing budget or the spawn route differs from those fixtures, Windows users stay broken and the issue is already closed. Please run one build of this head on Windows 11 (the reporter or any host). The run must reach npx agent-device doctor --debug and show: no EPERM in ~/.agent-device/daemon.log; a 20-digit processStartTime in daemon.json; a second CLI command reusing the daemon with no "Daemon registration busy" and no "not-running" termination; and agent-device daemon stop reporting exited. Until that run exists, please change "Closes #3291" to "Refs #3291".

Not blocking, and you can take or leave these: the row body at host-process.ts#L268 calls $_.CreationDate.ToUniversalTime() with no null check, so a null CreationDate (System Idle Process, pid 0) would raise InvokeMethodOnNull, exit 1, and make the full-table query return [] at line 362; the rule would be "any null field prints empty, never raises", for example if ($_.CreationDate) { ...ToString(...) } else { '' }, and the f1 run can check exit 0 on the full-table query; the comment at line 261 calls the stamp "culture-invariant" but a custom ToString format uses the current culture's calendar, so pass [Globalization.CultureInfo]::InvariantCulture or drop the claim; no test fills zombiePids in owned-process-record.test.ts#L11, so the new facts.zombie branches are untested; and classifyOwnerLiveness at owner-identity.ts#L77 repeats guards that classifyOwnerLivenessFromObservation runs again, costing two kill(0) calls per call.

The Windows branch sits at the right place, the host-process seam, and I found no smaller owner. One question: what enumerates the five isWindowsHostPlatform() checks (readProcessIdentityFacts, isProcessZombie, readHostProcessIdentityObservations, readProcessField, listHostProcesses)? Would a single HostProcessTable, chosen once with a posix and a cim implementation, make the next platform probe one implementation instead of another branch? This is optional at 205 net lines.

The earlier thread on the synchronous PowerShell spawns still applies (discussion); it is a latency cost, and the Windows run should report it. The two P3 threads on the UTF-8 output encoding (discussion) and on the log truncation (discussion) are fixed at this head.

I read the code at bad805c and did not run the test suites or any Windows host. The PowerShell output shape, the null CreationDate exit code, cold PowerShell latency against the 2 s budget and NTFS truncate behaviour are reasoned, not run. CI is green with 19 of 19 checks passing, but no lane runs on Windows, so it covers only the unchanged POSIX side of these routes. No conflicts. Before merge, the Windows 11 doctor --debug run above must show the daemon reused and stopped cleanly.

…mp culture-pinned

The row body claimed a culture-invariant stamp while ToString took no
culture argument, so a host calendar could reformat the same process birth
between the daemon's publish and a client's verify. And Formatting a null
CreationDate (the System Idle Process row) raises mid-pipeline: exit 1
blanks the whole snapshot rather than that row, turning one odd process
into unknown evidence for every pid. The row rule is now what it claims:
null fields print empty, never raise; birth is formatted with the
invariant culture. Reaper coverage gains the zombie identity it never
seeded, and owner-liveness guards collapse into one place so the single
snapshot path stops paying two kill(pid, 0) per poll.
…eness rules

The two stdout parsers had grown an identical five-line pid/ppid walk, and
the shared liveness judge stacked its probe-mode ternaries past the
complexity threshold. Both formats now feed their own pattern and builder
through one row walk, and the zombie and PID-reuse questions answer from
named rules that own their unknown-evidence semantics. No baseline moved:
the findings resolved at their shapes.
@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Blocking gate applied and all four findings fixed at their owning rules. Head a7821ef58; pnpm check:affected --run: all runnable checks passed; fallow clean on changed files with no baseline moved.

Refs, not Closes — body changed; closingIssuesReferences now returns [] via the API, so merge will not auto-close #3291. Stating it plainly: the Windows 11 checklist (doctor --debug with no EPERM in daemon.log; the daemon.json processStartTime equal to the daemon.lock owner; a second command reusing the daemon with no "Daemon registration busy"; daemon stop reporting exited/retired) is an unmet pre-merge condition for a human with hardware. No run on this branch can produce it, and none is claimed.

1 + 2 — the row body (16ff5d83b). Comment and code now agree on one rule: a null field prints empty, never raises. The ForEach became
$birth = if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssfffffff', [System.Globalization.CultureInfo]::InvariantCulture) } else { '' }
— invariant culture passed, so the comment's claim is the code, and a null CreationDate (System Idle Process) yields an empty stamp instead of InvokeMethodOnNull → exit 1 → [] blanking the whole table. The full-table fixture now includes the 0|0||System Idle Process row, and the new test pins both the null-birth parse and the culture argument in the emitted command. Mutation-checked: dropping the guard or the culture argument each fails the new test.

3 — zombie coverage seeded (16ff5d83b). owned-process-record.test.ts gained 'startup treats a zombie identity as missing without signaling it': zombiePids.add(303), asserts missingPids includes the pid, zero kill calls, record cleared. Mutation: deleting if (facts.zombie) return 'missing'; fails exactly this test.

4 — guard collapse (16ff5d83b, probes counted). The duplicated isProcessPid/isProcessAlive pair is now one guardOwnerPid shared by both entry paths — the rule lives in one place, satisfying the comment's intent. The re-check inside classifyOwnerLivenessFromObservation stays deliberately: its snapshot precedes the judgment, so an owner that died in between must read dead; that is a second call-site of one rule, not a duplicated rule. The snapshot path itself now pays exactly one kill(pid, 0), pinned by a new probe-count test (mutation: re-adding a redundant probe fails it).

The design question — six isWindowsHostPlatform() checks vs a HostProcessTable port. I decline, with the reasoning rather than hand-waving. The six checks are one decision made once per public entry of a single seam (zombie, snapshot, field, facts, list, + the predicate itself); both arms of each branch bottom out in the same two primitives — runCmdSync(HOST_PS_COMMAND…) or readWindowsProcessRows. A port with posix/cim implementations would need a type covering exactly today's exported API, and the next probe would then need a method in both implementations (two edits, one of them dead code) instead of one arm that can fall through to the shared POSIX body. The dispatch wouldn't disappear, it would move into a factory keyed on the same process.platform. What the split would buy is realized cheaper: a7821ef58 just factored the shared row walk (parseProcessTableRows(pattern, build)), so the divergence is now two pattern/builder pairs over one walk — if a third genuinely divergent table format ever appears, the extraction seam exists and the port becomes two pattern objects plus a factory, not a restructure. At 205 net lines with two formats, one branch per entry is the honest shape; you called it optional and I agree it stays optional.

Also from this round's earlier pass: fallow's changed-file audit flagged a new clone group (the two parsers' shared five-line pid/ppid walk) and a complexity breach on the extracted judge; both fixed by shape in a7821ef58 — one row walk for both formats, and the zombie/PID-reuse questions lifted into named rules that own their unknown-evidence semantics. No suppression, no baseline edits.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Lane note for a7821ef58: the iOS Smoke 'Preflight iOS runner through public CLI' step failed attempts 1–2 and passed attempt 3 and the failed-job rerun (attempt 4) — final state 18 success, 1 skipped. Same class as the 0743ddba0 flake ruled earlier: kind: daemon_startup_failed with a 0-byte daemon.log in the job artifact (daemon died before its first log write), and the error's own cleanupResults show the retired predecessor had a POSIX lstart identity ("startTime": "Fri Oct 9 03:40:22 2026") and terminated graceful/exited — the identity and stop machinery visible in the failure are healthy.

Because my recent commits did touch POSIX code (/bin/ps pin, row walk, liveness guards), I did not wave it through on precedent — at this exact head I ran the preflight's own shape against the live source 15 cold-state cycles: doctor → second command (same daemon pid, daemon.json.processStartTime equal to the daemon.lock owner's POSIX start time, no "registration busy") → daemon stop (graceful, cleanupConfidence: known) — 15/15 green, zero EPERM in any daemon.log, plus a direct check that the now-/bin/ps batched snapshot (-p a,b -o pid=,state=,lstart=) behaves identically to PATH ps on macOS. The 1d3d2b0a0 pin runs the same /bin/ps the per-field probes always used; the only behavior delta would need a PATH ps differing from /bin/ps, which is exactly the hostile-PATH case the pin removes. Two same-signature fails on shared macOS runners with a 0-byte log, passing on rerun, with the local lifecycle loop green, is the runner pool, not this diff. The Windows live checklist remains the one genuinely unmet gate and is unchanged by this.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

First red at a7821ef58 (the coordinator's link, job 113653272085, failed 03:23:40Z — this job actually failed three attempts, 03:23/03:30/03:40, my earlier lane comment undercounted; correcting the record), re-run GREEN: job 113660776342 completed 04:01:10Z, final check state at this head 18 success / 1 skipped. The shared row walk cannot affect the POSIX startup path because parseProcessTableRows has exactly two callers — parseHostProcessList (the ps -ax full-table list) and parseWindowsProcessRows — while the startup owner probe reads single-field values through readProcessStartTime/readProcessCommand → processFieldValue, and the snapshot's inline pid=,state=,lstart= loop was untouched by a7821ef58 (its diff is two files: the parser factoring in host-process.ts and the liveness-rule naming in owner-identity.ts, neither in the snapshot loop or field-value path); host-process.test.ts 'host process parser preserves command text after pid and parent pid' and 'host process listing uses injected command runner and returns parsed ps rows' pin the POSIX row parsing byte-for-byte across the refactor, and the 15/15 local doctor→reuse→stop cycles plus that full focused suite were green at this exact head before the push.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Timing test run on this macOS host, preflight shape (fresh AGENT_DEVICE_STATE_DIR, first command spawns + waits for a reachable owner, second command must reuse, then stop), wall-clock seconds:

head run spawn→ready reuse rcs
bad805c21 (base) 1 3.04 0.34 0/0/0
bad805c21 2 2.66 0.23 0/0/0
bad805c21 3 2.61 0.23 0/0/0
a7821ef58 (head) 1 3.12 0.23 0/0/0
a7821ef58 2 2.64 0.23 0/0/0
a7821ef58 3 2.66 0.23 0/0/0
a7821ef58 (re-run after base, same machine state) 4–6 2.64 / 2.68 / 2.68 0.23–0.34 0/0/0

Head is not materially slower than base — medians 2.67 s vs 2.66 s, inside run-to-run noise, and base's own first run matched head's worst. Six clean head cycles at this exact checkout plus three base cycles: daemon always published a reachable daemon.json, reuse always hit the same pid, stop always graceful, nowhere near the 15 s budget. The diff is exonerated on startup latency; no added probe exists to fix — and the mechanism check agrees: a7821ef58's non-test surface is the row-walk factoring (only reachable from parseHostProcessList/parseWindowsProcessRows, as the coordinator's correction says) and branch-for-branch-equivalent liveness-rule renames; the POSIX snapshot loop and processFieldValue were untouched.

Bisect status: the 16ff5d8 iOS rerun was cancelled at 03:50:33Z by workflow cancel-in-progress concurrency (the 03:42 push to this branch cancelled it), so green-at-16ff is still unobserved, not green. I re-triggered failed-job attempt 3 at 16ff5d8; outcome will be reported here.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Closing the lane question. The cross-head bisect is dead as evidence and I have stopped using it: my 16ff5d8 re-run (attempt 3) was still burning a lane slot under the same cancel-in-progress concurrency group, so I force-cancelled it rather than let it eat another ref push. Cross-head iOS re-run bisection is structurally unusable on this branch; the local timing table in the previous comment is the discriminator and it is equivalent (medians 2.67 s head vs 2.66 s base, six head cycles, three base cycles, all clean).

Attempt 4 at a7821ef58 came back green (job 113660776342, completed 04:01:10Z; run conclusion success, check state 18/18 non-skipped green). Green attempt + equivalent timings + untouched startup-path code surface + three same-signature fails with 0-byte daemon.log that pass on retry = straight lane flake, #3342 class. The Windows live checklist remains the only genuinely unmet gate on this PR; nothing else changed.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Thanks for the update. At a7821ef the code delta looks clean, and it leaves POSIX startup unchanged. The one thing still missing is a run on Windows.

Every Windows route is still proved only by hand-written fixtures at the exec and fs seams: the CIM row body, the PowerShell spawn, the 2 s budget and the NTFS log truncate (host-process.ts#L287). No lane runs on Windows, and no Windows host run exists at this head. If the real PowerShell output, cold-start latency or spawn behavior differs from the fixtures, users of #3291 stay broken. The PR now says Refs, so the issue will not auto-close, but merging would still ship an unproven fix for the reported route. Please run one build of this head on Windows 11 and reach npx agent-device doctor --debug. The run should show no EPERM in ~/.agent-device/daemon.log, and a 20-digit processStartTime in daemon.json that equals the daemon.lock owner. A second CLI command should reuse the same daemon pid, with no "Daemon registration busy" and no not-running termination. agent-device daemon stop should report exited. Please also report the wall time of the full-table CIM query and its exit code 0, which confirms the null-CreationDate row no longer blanks the table.

On the open threads: the readWindowsProcessRows sync spawn with a 2 s budget thread still applies. It is a latency cost, not a correctness defect, so the Windows run should measure it. The UTF-8 output encoding thread and the log-truncate handle thread are both fixed at this head, so you can resolve them.

CI is green on the final state (18/18 non-skipped). iOS Smoke preflight failed on attempts 1-3 with daemon_startup_failed and an empty daemon.log. That looks unrelated, because the delta only touches the shared row walk and an equivalent liveness-guard refactor, not the POSIX startup path. I did not run the test suites or the mutation checks, so I have not verified those claims. Before merge, we need the Windows 11 run above for a7821ef.

@pai-scaffolde

Copy link
Copy Markdown

Native Windows run of the #3330 checklist at head a7821ef58 (package 0.21.24-dev).

Host: Windows 11 Enterprise 10.0.26200, Node 24.21.0, plus Electron 44.4.2 as runtime (ELECTRON_RUN_AS_NODE=1). Default socket transport and HTTP transport both tested. Fresh AGENT_DEVICE_STATE_DIR per run.

Checklist item Result Evidence
doctor --debug, no EPERM in daemon.log Pass exit 0 in 3.5 s; daemon.log holds only the port line; EPERM count 0 (socket and HTTP)
CIM processStartTime in daemon.json Pass "processStartTime": "202610091601014504740" for the daemon pid, an exact match for its CIM CreationDate (UTC)
Daemon reused by a second command Pass devices --json after doctor: same pid, 588 ms, one daemon process in CIM. HTTP + Electron: same pid, 503 ms
daemon stop exits, process gone Pass Daemon stopped (graceful)., exit 0 in 1351 ms, pid gone. HTTP + Electron: 1265 ms, pid gone

Baseline on the same host (0.21.12, HTTP, Electron): daemon.json has no processStartTime, and daemon stop fails with Refusing to stop a daemon without a verified process start-time identity., which leaves the daemon running.

Items the PR body says are still owed:

  • Non-ASCII CommandLine: pass. The checkout was reached through a junction whose path contains ü ï ø é, with a state dir that also has non-ASCII characters. Start (with processStartTime), reuse (same pid) and graceful stop all work.
  • CIM latency: a standalone powershell.exe -NoProfile single-PID query took 324, 331 and 345 ms over 3 runs. That's warm, not after a reboot, so it isn't a true cold number.
  • PowerShell-per-poll stop cost: not measured separately. End-to-end daemon stop took 1265–1351 ms.

Not covered: a cold-boot CIM timing, and iOS/macOS paths.

One unrelated Windows build issue showed up while building this head. pnpm build fails with Could not resolve '@limrun/xdelta3-wasm' ... Matched alias not found, because tsdown.config.ts builds the alias path with new URL(..., import.meta.url).pathname, which gives /C:/... on Windows. With fileURLToPath(new URL(...)) from node:url the build completes. I can open a separate PR for that if useful.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

@pai-scaffolde — thank you. This is the evidence this PR could not produce from its own environment, and the run is exactly the shape the review asked for. Stating plainly which of my own open questions your run closes:

  • The Windows host: daemon never becomes reachable (0.21.23) #3291 symptom itself is gone on the failing platform. doctor --debug exit 0 with zero EPERM and a daemon.log holding only the port line means the r+ truncation path works on real NTFS — previously every Windows start died in that ftruncate.
  • CIM birth identity is live-correct. "202610091601014504740" matching the daemon pid's CreationDate to the digit proves the yyyyMMddHHmmssfffffff format, the UTC conversion, and the invariant-culture pin on a real host — my tests asserted the query shape; you asserted the answer.
  • Registration reuse and stop. Same-pid reuse on both transports and graceful stop with the pid gone exercise the full verify-identity chain (daemon.json birth stamp vs CIM re-read) that POSIX-only CI can only simulate.
  • The base-vs-head separation — 0.21.12 on the same host publishing no processStartTime and refusing stop, head doing both cleanly — is the discriminator none of us could construct here. It rules out "the host is just permissive."
  • Non-ASCII round-trip through the UTF-8 pin — your üïøé junction + non-ASCII state dir is the item I had listed as unverified at every seam, mock included. Start/reuse/stop over those bytes is byte-level proof my CIM rows survive the OEM-code-page pipe. That one closes the residual I rated highest.

What stays open, recorded honestly and not going back to you for more numbers: the reviewer asked for the full-table CIM query wall time with exit 0, and what exists is a warm single-PID query at 324/331/345 ms (your own caveat: not cold-boot). Cold-boot latency and the PowerShell-per-poll share of the 1.2–1.4 s stop cost remain unmeasured — a latency note the reviewer already ruled "cost, not correctness defect," now bounded by your end-to-end stop numbers far tighter than anything I could claim.

The PR body's evidence section now records your run (head, host, runtime versions, both transports, the baseline contrast) so it survives being buried in this thread. The tsdown.config.ts build failure you found is real and out of scope here (it reproduces on main, untouched by this diff) — filed as #3353, and your offer to open a separate PR for the fileURLToPath fix is very welcome.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Thanks @pai-scaffolde. Your native Windows run at a7821ef gives the live evidence the earlier review asked for: start without EPERM, the CIM start-time identity, daemon reuse, and graceful stop, on both transports and with non-ASCII paths. The 0.21.12 baseline on the same host also shows the change is what fixes it. Nothing from the review is still open at a7821ef, and there are no conflicts.

@thymikee
thymikee merged commit 87c5b3b into main Oct 9, 2026
19 of 22 checks passed
@thymikee
thymikee deleted the fix/windows-daemon-start-3291 branch October 9, 2026 18:30
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-09 18:30 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants