Skip to content

fix(daemon): read a macOS daemon start time in UTC, like the agent-session probe - #272

Merged
nplez1 merged 4 commits into
nplez1/mainfrom
nplez1/fix-daemon-read-a-macos-daemon-start-time-in-utc
Nov 2, 2026
Merged

nplez1 merged 4 commits into
nplez1/mainfrom
nplez1/fix-daemon-read-a-macos-daemon-start-time-in-utc

Conversation

@nplez1

@nplez1 nplez1 commented Nov 2, 2026

Copy link
Copy Markdown
Owner

The bug

Fixes #269. ps -o lstart= prints local wall clock, and the hour a DST fall-back repeats resolves to
the earlier offset, so for one hour a year a daemon start time read back exactly one hour early.
#268 fixed this in the agent-session probe; the daemon paths still had it, and neither value is
compared against itself — both meet a recorded epoch, so the error does not cancel:

  • readMacosProcessStartedAtMs feeds probeMacosProcess in daemon-incarnation-evidence.ts, where an
    hour of error turns "this is our daemon" into process_start_time evidence that does not match, so
    incarnation evidence degrades to unknown.
  • getPsProcessIdentityAsync feeds daemon-pid-identity.ts and macos-retired-daemon-recovery.ts,
    where the identity captured before a kill is compared with the one read after it to prove the daemon
    is gone.

Measured on 2026-11-01 (the US fall-back date) for a process started at 01:37 PST:

lstart                 "Sun Nov  1 01:37:04 2026"
Date.parse(lstart)   → 08:37:04Z      real start → 09:37:04Z      delta −3 600 709 ms
TZ=UTC + " UTC"      → 09:37:04Z      delta −709 ms

The fix — one zone for every lstart→epoch conversion

ps is asked for lstart with TZ=UTC and the text is read as UTC. The two idioms #268 added move
from the agent-session probe into src/shared/ps-lstart-time.ts (psUtcEnvironment, parseUtcLstart),
so every call site reads one implementation instead of three copies. runPsInProcess formats its own
rows for the env it is given, so the in-process reader and the /bin/ps fallback agree.

  • Daemon inspection — readMacosProcessStartedAtMs parses with parseUtcLstart, and
    runInspectionCommand hands psUtcEnvironment() to both readers.
  • Daemon identity — the sync and async twins both pin the zone, and getPsProcessIdentity's utc
    option is gone rather than defaulted: that fixed getProcessStartedAtMs
    (daemon-process-start-time.ts:66), a third call site with the same defect the issue did not list. A
    wrong default is the shape this bug keeps returning in.
  • Descendant and provider tables — the two remaining ps readers that produce lstart text pin
    TZ=UTC next to the C locale they already pinned, and hasUnambiguousStartTime reads that text with
    parseUtcLstart. Without this the recovery comparison below, and the second-boundary PID-reuse proof
    that gates SIGKILL escalation, would be reading a local string as UTC.
  • Recovery — macos-retired-daemon-recovery compared Date.parse(snapshot.root.startedAt) with
    the daemon's epoch; the two sides are now in the same zone, so the check keeps proving the snapshot's
    root row is the daemon instead of being dropped.

The identity query also inherits the en_US.UTF-8 locale upstream pinned for macOS ps (stablyai#33898) instead
of overriding it with LC_ALL=C, so the command= half of the identity is no longer vis-encoded for
non-ASCII paths.

String-vs-string lstart comparisons (managed-hook-owner-identity.ts,
mac-browser-sidecar-recovery.ts, pty-descendant-termination.ts's live-row match) compare two reads
of the same process in the same zone, so the zone cancels and they are deliberately untouched.

What the review changed

Round 1 caught the defect the first version introduced: pinning the daemon identity to UTC left
macos-retired-daemon-recovery comparing that epoch with the descendant table's local lstart text.
Both sides used to be local, so they agreed; after the fix a daemon started in the repeated hour
differed by 3 600 000 ms and recovery threw. That is why this PR ends up pinning the descendant and
provider readers too, instead of dropping the comparison.

Round 2 (delta) found that nothing guarded the two new reader pins — dropping one turned the
PID-reuse proof into "always unambiguous" on a Mac west of UTC, with no test red — and that
daemon-tcc-attribution-main-thread.test.ts was already red on this machine from round 1, because its
fixture built a pid record from a local Date.parse of the same text its ps mock returns. Both are
fixed with tests that fail when the pin or the parse is reverted, each verified by reverting it.

The full unit gate then caught one more fixture of that shape — daemon-incarnation-evidence-main-thread
mocked ps with new Date(...).toString(), which carries a GMT offset and is not the fixed-width
lstart ps prints; Date.parse accepted it and parseUtcLstart correctly does not.

Known one-time window

A start time recorded by the previous build (in a daemon PID file, orcad's record, or profile
state) was parsed local and is compared within a tolerance against the new UTC reads. A daemon or
orcad that started in the repeated hour of a fall-back day and is still alive across the upgrade can
mismatch once, by one hour. The window is one hour, twice a year, and it degrades to "unknown" identity
rather than to a wrong kill; no migration code is worth it, and this is recorded here rather than
silently absorbed.

Verification

  • src/shared/ps-lstart-time.test.ts — the repeated-hour string parses to
    Date.UTC(2026, 10, 1, 1, 37, 4), with TZ stubbed to America/Los_Angeles and a premise assertion
    that a local read of the same string cannot yield it, so the guard bites on a UTC runner too instead
    of only on a non-UTC developer machine.
  • src/main/daemon/daemon-process-inspection.test.ts — the same guard through the daemon reader, plus
    a live darwin case that reads this process's own start time with TZ stubbed; it fails if either the
    in-process reader or the /bin/ps fallback loses the zone pin (verified by reverting the env and
    watching it red).
  • src/main/daemon/daemon-process-identity-time-zone.test.ts — both twins assert the UTC parse, that
    ps gets TZ=UTC, and that the async twin passes the same environment as the synchronous one.
  • Targeted tests, all green: the files above plus daemon-health, daemon-bundle-staleness,
    macos-retired-daemon-recovery, profile-state-access-identity, agent-session-process-identity-probe,
    pty-descendant-termination (+-libproc), pty-descendant-exit-verification,
    provider-process-row-read, provider-group-orphan-guard (+integration), managed-provider-process
    (+fallback-tree), terminal-descendant-shutdown (+-libproc), claude-agent-sdk-exit-proof
    (+-identity, -root-kill-fallback), claude-descendant-escalation-boundary, and the three
    structured-agent-session-*-kill-evidence suites.
  • pnpm tc:node and pnpm tc:web clean; pnpm run check:code-quality:changed nplez1/main clean;
    node local/check-upstream-file-comments.mjs at baseline (6026/6026).

Reviewed by Opus 5.5, high: R1 (1 blocking fixed), delta on R2 (2 blocking fixed)

  • Gate 1, the full unit suite (required: the diff touches src/shared/): Unit gate @3d442589e9 (clean): PASS — 135050 passed; 0 failed (0 allowlisted, 0 unexpected); 0 flaky re-run passed; 625 s

…ssion probe

`ps -o lstart=` prints local wall clock, and the hour a DST fall-back repeats
resolves to the earlier offset, so for one hour a year a daemon start time read
back exactly an hour early. #268 fixed that in the agent-session probe; these
daemon paths still had it, and neither value is compared against itself — both
meet a recorded epoch, so the error does not cancel.

- `readMacosProcessStartedAtMs` feeds `probeMacosProcess`, whose start-time
  evidence then fails to match the incarnation it is verifying, degrading
  evidence to `unknown`.
- `getPsProcessIdentityAsync` feeds the PID-identity and retired-daemon-recovery
  comparisons, which use it to prove the daemon is gone.
- `getProcessStartedAtMs` reached the same defect through the synchronous twin's
  default, so the zone is now pinned unconditionally and the `utc` option is gone
  rather than left as a wrong default.

The two idioms #268 added move into `src/shared/ps-lstart-time.ts` so all three
call sites read one implementation instead of three copies. The identity query
now inherits the en_US.UTF-8 locale upstream pinned for macOS ps, which keeps
non-ASCII argv readable in the command line it matches on.
… UTC read still matches

Round 1 (Opus, high) found one blocking defect in R1: `macos-retired-daemon-recovery`
compared `Date.parse(snapshot.root.startedAt)` — the descendant table's local wall
clock — with the daemon identity's epoch, which R1 had just made UTC. Before R1 both
sides were local and matched (wrong by the same hour); after it, a daemon started in
the repeated hour differed by 3 600 000 ms and recovery threw "Could not verify the
retired macOS terminal service".

One zone now covers every lstart-to-epoch conversion, which is what makes the
comparison coherent again rather than dropping it:

- the fresh descendant table and the provider row reader ask ps for lstart with
  TZ=UTC, next to the C locale they already pin;
- `hasUnambiguousStartTime` and the recovery comparison read that text with
  `parseUtcLstart`, so the second-boundary PID-reuse proof is also right in the hour
  that repeats, instead of reading a start an hour into the future.

Round 1's other two findings: the repeated-hour guards now stub TZ to
America/Los_Angeles, because on a UTC runner a local read satisfies them and the
guard would be vacuous (the shared test asserts that premise, so the guard cannot
quietly stop biting); and a live darwin test reads this process's own start time with
TZ stubbed, which fails if either the in-process reader or the ps fallback loses the
zone pin — verified by reverting the env and watching it red.

Recorded start times are noted in the PR body: a daemon or orcad that started in the
repeated hour and is still alive across the upgrade can mismatch by one hour once.
…ay the code does

Round 2 (Opus, high) found that nothing tested the two `TZ: 'UTC'` pins R2 added to
the ps readers, and that `daemon-tcc-attribution-main-thread.test.ts` was red — an R1
miss it caught from the delta.

The pins are safety-relevant, not cosmetic: a local `lstart` text parsed as UTC on a
Mac west of UTC reads every row hours older, so `hasUnambiguousStartTime` clears a
start it cannot distinguish from PID reuse and SIGKILL can reach a recycled PID.
Dropping either pin failed no test on any runner. Each reader now has a guard that
fails when its pin is removed — the fresh descendant table asserts the `ps` env, and
the provider row reader reads this process's own marker with `TZ` stubbed and checks
it parses to the real start instant. Both verified by removing the pin and watching
them red.

The tcc attribution fixture wrote a pid record from a local `Date.parse` of the same
`lstart` text its `ps` mock returns, which the daemon identity now reads as UTC, so
the 1 500 ms comparison became a 7-hour mismatch on this machine and the suite was
red here while green on UTC CI. It and one leftover fixture now use `parseUtcLstart`.
…te string

The unit gate caught this one: the mock emitted `new Date(1_700_000_000_000).toString()`,
which carries a GMT offset and the zone name, so it is not the fixed-width lstart that
`ps -o lstart=` prints. `Date.parse` accepted it; `parseUtcLstart` reads the first 24
characters and correctly refuses it, so the evidence fell to `unknown`. The fixture now
emits the lstart for that same instant.
@nplez1
nplez1 merged commit 25ecc60 into nplez1/main Nov 2, 2026
19 checks passed
@nplez1
nplez1 deleted the nplez1/fix-daemon-read-a-macos-daemon-start-time-in-utc branch November 2, 2026 09:06
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.

fix(daemon): read a macOS daemon start time in UTC, like the agent-session probe

1 participant