Skip to content

fix(daemon): replace fixed 15s startup kill with progress-aware startup budget - #3791

Open
thymikee wants to merge 22 commits into
mainfrom
fix/daemon-startup-budget
Open

thymikee wants to merge 22 commits into
mainfrom
fix/daemon-startup-budget

Conversation

@thymikee

@thymikee thymikee commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #3782. Startup previously killed healthy daemons after 15 seconds on loaded hosts (#3342). CLI, MCP, and Node now share a 120-second ceiling and a 60-second window without progress.

AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS=180000 agent-device devices

The setting requires positive integer milliseconds. Invalid values fail with INVALID_ARGS before spawn, only when a daemon must start. Attach and reuse ignore it. Typed reasons distinguish startup-exited, startup-stalled, and startup-ceiling. A live competing startup is preserved. One startup-record module owns the format, writer, reader, and sweep. Scope: 22 files, 1,827 added and 673 removed lines.

Validation

Head 0295ee5f23; base 0fb25c474a. Repairs: the ceiling prose is scoped to the client's own launch (another client's live startup can reach the ceiling without observed progress), the no-marker budget test unsets the variable instead of stubbing 120000 so the default ceiling is proven against host overrides, and a stray CHANGELOG blank line is gone. Same 22 files, gross 2,500.

Receipt run-Q4YXDq passed: exactCommit=true, checkoutChanged=false; 36 checks passed, 25 skipped, 2 deduplicated. Host HarmonyOS SDK variables were unset (env -u DEVECO_SDK_HOME -u HDC_SDK_PATH -u HARMONYOS_COMMAND_LINE_TOOLS); that hdc test is unchanged here and fails only with those exports present. Independent review is complete. No findings remain. CI is pending.

The headline test advances four distinct phases across 80 seconds of virtual time under a 150-second ceiling; disabling marker reads makes it fail. This is unit evidence; the CI cold start measured 330 ms at 8ccb661. No real slow-host result beyond 60 s is claimed.

@thymikee

Copy link
Copy Markdown
Member Author

Evidence

Base: 4a8abed7b969cffc47eb2618e8dfb804116ff85b (origin/main) · Head: 8058ef25f42e8cbfbf1385c5dc65e54475baf579 · Worktree: ~/.t3/worktrees/agent-device/t3-57957535, branch fix/daemon-startup-budget.

Local gate: check:affected --run --head 8058ef25f4 → passed; receipt .tmp/validation/run-blibL1/receipt.json (exactCommit=true, checkoutChanged=false). format, lint, typecheck, layering, fallow, build, vitest-related, docs-links, docs-prose all passed.

New regression tests (src/daemon-client/__tests__/daemon-client-startup-budget.test.ts, 6 tests): slow-but-successful start >15 s succeeds; daemon exit detected <1 s with reason startup-exited; live daemon with no markers fails as startup-stalled; env override raises the ceiling; invalid env values rejected with INVALID_ARGS before spawn; ceiling failure on an alive daemon maps to startup-ceiling.

Base/head proof: disposable checkout at base with only the behavioral change reverted → all 6 tests fail for the intended reasons (no ceiling, no reasons, no env override); at head all 6 pass.

Focused suites at head: startup-budget + startup-race 31 passed; lifecycle + ensuring + explicit-lifecycle 77 passed; registration-owner + daemon-entrypoint + help 79 passed. Existing lifecycle/startup-race tests keep their meaning (15 s budget pinned via vi.stubEnv).

Live route proof: node bin/agent-device.mjs devices started a real daemon from this worktree; all four phase markers with timestamps written to daemon-startup-phases.jsonl beside the registration; graceful daemon stop afterwards.

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3791/

Built to branch gh-pages at 2026-10-18 14:19 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.40 MB 5.40 MB +5.4 kB
Package (unpacked) 5.40 MB 5.40 MB +5.4 kB
Package (download) 1.63 MB 1.63 MB +2.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.5 ms 28.1 ms +1.6 ms
CLI --help 52.0 ms 52.9 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 8 files

You’re at about 98% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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

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

Comment thread packages/daemon/src/server/http/daemon-runtime.ts Outdated
Comment thread packages/daemon-contracts/src/daemon-registration-owner.ts Outdated
Comment thread src/daemon-client/daemon-client-lifecycle.ts Outdated
Comment thread src/commands/schema/cli-help.ts Outdated
Comment thread packages/daemon-contracts/src/daemon-registration-owner.ts Outdated
Comment thread website/docs/docs/configuration.md Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Integration Tests failed at 8058ef25f4. The raw job log shows three leak-oracle failures after shutdown. Each names the new daemon-startup-phases.jsonl file as the sole stray entry. No daemon or owned process remained alive.

This is a PR defect, not a known CI flake. I assigned a lifecycle fix with red/green verification through the three failing integration tests. The leak oracle stays active. I did not rerun CI.

The preview comment needs no action. The Size report shows +3.2 kB unpacked. The independent review must assess whether a smaller design can meet #3782. Review and repair are in progress; the PR is not ready.

@thymikee

Copy link
Copy Markdown
Member Author

I checked all six cubic claims against 8058ef25f4.

  • The restart-marker claim is valid: the parser keeps the earliest phase before the client filters out old timestamps. A previous launch can hide current progress.
  • The phase-gap claim is partly valid. composeDeps runs before lock acquisition. Recovery and orphan cleanup run after it without intermediate markers. The repair must record completed phases, not a heartbeat for work that can be stuck.
  • The stall hint incorrectly suggests that the ceiling setting changes the fixed stall window. The fix will retain the environment variable name required by Daemon startup: wait for a live daemon instead of a fixed 15 s deadline on slow or overloaded hosts #3782 and explain its actual effect.
  • The ceiling description is incorrect when the configured ceiling is below 60 s. Both help surfaces and the error hint need the same correction.
  • The positive-integer requirement is missing from the configuration text.
  • Marker-write failure needs a producer/client regression before we choose its treatment. I have not accepted the proposed extra state or fallback design.

The current Apex repair worker now owns these items with the integration cleanup defect. This keeps one editor on the PR. Independent review continues. Threads remain open until a verified fix or assessment is posted.

@thymikee

Copy link
Copy Markdown
Member Author

Independent review of 4a8abed7b9..8058ef25f4 confirms the restart-marker defect. The reviewer reproduced the old-marker selection and the empty current-launch observation. The review also confirms the incorrect ceiling hint and identifies unbounded marker-file retention.

The current repair worker owns these findings with the integration cleanup defect. It must also split the startup responsibility from daemon-client-lifecycle.ts, as code-ownership.md requires before behavior is added to a file over 1,000 lines. It will remove the redundant exit-promise guard after it verifies the producer contract.

The reviewer treats the long gap between phases as a risk, not a demonstrated defect. Completed-phase markers can improve observability. A heartbeat must not make stuck work appear to progress.

The reviewer ran 270 daemon-client tests and the focused registration, entrypoint, close, and help suites. They passed at the reviewed head. This does not replace the failed Integration Tests lane. A new independent review and CI pass are required after repair. The PR remains not ready.

thymikee added a commit that referenced this pull request Oct 17, 2026
…ts owner

The integration leak oracle failed because every daemon appended its
startup phase markers to one shared `daemon-startup-phases.jsonl` beside
the registration and nothing retired that file, so any completed launch
left state-dir residue the strict oracle names as a stray entry.

Ownership now follows the file name: each launch writes only to
`daemon-startup-phases-<pid>.jsonl` through
`resolveDaemonStartupPhasesPath(stateDir, pid)`. The writing daemon
removes its file once it has registered; the launching client removes it
once that process's exit is joined (in `retireStartupAttempt`, after the
exit is observed, never before); and each new registration-lock owner
sweeps the files of provably dead pids next to the shutdown-report
removal. A live pid's file is always kept, so a concurrent or replaced
launch can neither add to, hide behind, nor delete another launch's
evidence, and the client no longer needs `spawnedAtMs` to attribute
markers to its own launch.

Along the protocol boundary:

- `recordDaemonStartupMarker` takes an optional `reportFailure` callback
  so producers surface a refused append on stderr (the daemon log tail
  the waiting client already ships as failure evidence) instead of
  swallowing it; the append stays non-fatal and a crash-truncated final
  line is separated so it cannot glue two records together.
- The phase list gains real completed boundaries, `composition-loaded`
  and `recovery-finished`, and no heartbeats, so silence still means a
  stalled startup and progress extends the stall window exactly as far
  as the work took.
- The launch-exit join drops its redundant `.catch(() => {})`: the
  `runCmdDetachedMonitored` owner resolves the promise on error and
  exit, so it never rejects.
- Startup code splits out of `daemon-client-lifecycle.ts` into
  `daemon-client-startup.ts` (budget, wait loop, marker refresh, hints),
  keeping both modules under the 1000-line tripwire; importers attach
  and ensure point at the owning module directly.

Regression coverage: the budget suite now pins per-launch attribution,
including a fresh daemon producing its own markers while an earlier
launch's leftover file is swept at the new registration, and a new
protocol suite pins the per-pid paths, the dead-pid sweep, removal
idempotence, and the write-failure seam.

Refs #3782, #3791
thymikee added a commit that referenced this pull request Oct 17, 2026
The documented reasons overclaimed. `startup-ceiling` said "progressing,
not registered before the ceiling", but the ceiling is reached by a
daemon that is merely alive: a progressing one is only ever stopped by a
ceiling above the stall window, so the promise was unverifiable from the
client's evidence. `startup-stalled` named a 60 s window without saying
that `AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS` does not change it, so
raising the ceiling looked like a fix for silence. Both the CLI help and
the configuration table now describe the two reasons as what they are,
and the env var is specified as a positive integer number of
milliseconds.

Refs #3791
@thymikee

Copy link
Copy Markdown
Member Author

Follow-up: the Integration Tests leak failure on this PR is fixed (a2c6e74)

Cause. The daemon wrote its #3782 startup phase markers to a single shared daemon-startup-phases.jsonl beside the registration, and nothing retired that file. The leak oracle classifies unknown state-dir entries as stray by design, so every completed launch left residue — exactly the CI signature in run 39064618214. The oracle is unchanged.

Fix — ownership follows the file name. Each launch writes only daemon-startup-phases-<pid>.jsonl (resolveDaemonStartupPhasesPath(stateDir, pid)). Three owners retire it, each able to prove no writer remains: the daemon removes its own file after registered; the launching client removes it after the child's exit is joined (retireStartupAttempt); the new registration-lock owner sweeps provably-dead pids' files next to the shutdown-report removal (a live pid's file is always kept). Clients no longer need spawnedAtMs filtering — attribution is the file name — so a concurrent or replaced launch can neither add to, hide behind, nor delete another launch's evidence.

Red→green. smoke-daemon-http, smoke-daemon-clean, daemon-replace-exit-flush: all three red at 8058ef25f4 locally (same stray-entry message as CI), green at head. The new per-launch attribution test goes red at base with phases['lock-acquired'] undefined (verified in a disposable base checkout with only the test copied), green at head. Focused suites green: daemon-client 318, entrypoint/proxy/runtime 146, daemon-contracts 97, registration-owner 47. Local gate check:affected --run --head a2c6e74f5 receipt passed (exactCommit=true, checkoutChanged=false). GitHub lanes (Integration Tests, Coverage, native builds, device lanes) still run at the new head — nothing here claims those.

Review threads.

  • lock-acquired-only progress (j3C0): composition-loaded and recovery-finished added as real completed boundaries; no heartbeats, silence still means stalled.
  • earliest-per-phase attribution (j3C4): per-launch files make the dedup structurally intra-launch; test pins it.
  • stalled hint (j3C8), ceiling wording (j3DA), env parser wording (j3DF): hints, cli-help, and configuration.md now state what the code proves (stall window fixed at 60 s and unaffected by the ceiling var; ceiling = alive-but-unregistered; positive integer ms).
  • swallowed marker-write failure (j3DD): scoped — recordDaemonStartupMarker takes reportFailure, both producers log the refusal to stderr, which lands in daemon.log, already shipped as daemonLogTail failure evidence; the append stays non-fatal so a broken evidence channel never fails a registrable daemon (the stall-on-silence contract in Daemon startup: wait for a live daemon instead of a fixed 15 s deadline on slow or overloaded hosts #3782). Leaving open for maintainer judgment.

Also in this push. Startup code split out of daemon-client-lifecycle.ts into daemon-client-startup.ts (both < 1000 lines; git diff -M90% shows the move); redundant .catch(() => {}) on launch.exited removed (its owner in exec.ts resolves on error + exit, never rejects); crash-truncated final marker lines are separated so two records can't glue; protocol-level unit coverage for per-pid paths, the sweep, removal idempotence, and the write-failure seam.

@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 12 files (changes from recent commits).

You’re at about 98% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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

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

Comment thread src/__tests__/test-utils/registered-daemon-fixture.ts Outdated
Comment thread src/daemon-client/daemon-client-startup.ts Outdated
Comment thread src/daemon-client/daemon-client-lifecycle.ts Outdated
thymikee added a commit that referenced this pull request Oct 17, 2026
The lifecycle and startup module headers only narrate each file's role,
which AGENTS.md reserves for public APIs, tool directives, and
constraints code cannot show. The split is owned by the imports and the
file names.

Fixes review thread PRRT_kwDORFGHaM6tkpef on #3791.
thymikee added a commit that referenced this pull request Oct 17, 2026
…or it

The shared emit-startup-markers flag file applied to every later fixture
in the same state dir, so the stale-history cleaner inherited the
request, wrote its own live marker, and the sweep assertion raced it.
The request is now a typed per-child option bound into that child's
entry script, and readiness waits use a per-pid file so a stale
registration-held from an earlier child cannot shortcut the await.

Fixes review thread PRRT_kwDORFGHaM6tkpeV on #3791.
thymikee added a commit that referenced this pull request Oct 17, 2026
… exit

A launch that exited busy, unproven, or lost the registration to a
contending winner returned its retry, error, or winner handoff without
joining its exact exited child, so the child's pid-named phase file sat
in the state dir until some later registration sweep. Every terminal
child outcome now joins the child first and removes only that child's
own file, the same ownership rule a retired startup follows; the
current winner's registration and record are untouched.

Regression tests write markers at the real pre-acquisition producer
seam (new emitStartupMarkerBeforeAcquisition fixture option) and pin
the residue rule on the retry, winner-attach, and unproven-exit paths:
red at the prior head, green with the fix.

Fixes review thread PRRT_kwDORFGHaM6tkpec on #3791.
thymikee added a commit that referenced this pull request Oct 17, 2026
The per-pid file names its writer, not its lifetime: if client and
daemon of an old launch both died before either cleanup ran and the
host reused the pid, the old record survived under the new writer's
name, the sweep skipped it as live, and the earliest-per-phase reader
mixed two lifetimes into one observation. The producer now claims its
own record (claimDaemonStartupPhasesFile) before its first marker, so
stale same-pid content can neither mask nor extend the fresh startup's
progress. A refused claim follows the marker contract: reported, never
fatal. The test child repeats the real producer sequence against its
own pid, which no outside test can arrange.

Addresses review finding R2-1 on #3791.
thymikee added a commit that referenced this pull request Oct 17, 2026
describeStartupPhaseTimings builds a Record<string, number> that always
starts at spawned: 0; the intersection cast asserted what the literal
already showed. No caller branched on it.

Addresses review finding R2-2 on #3791.
thymikee added a commit that referenced this pull request Oct 17, 2026
The #3782 startup-budget evidence needs one measured successful cold
startup: after clean:daemon, the prepare command's daemon_startup
diagnostic now wraps a fresh managed startup and lands in the step log.
Behavior and JSON output are unchanged.

Records the evidence obligation for #3791.

@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 12 files (changes from recent commits).

You’re at about 98% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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

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

Comment thread src/__tests__/test-utils/registered-daemon-fixture.ts
@thymikee

Copy link
Copy Markdown
Member Author

Repairs at head 62a09bd298 (7 commits on a2c6e74f53).

Causes & fixes

  • A (PRRT…6tkpeV): shared persistent emit-startup-markers flag leaked into later fixture children and a stale registration-held shortcut the sweep await → typed per-child option + per-pid readiness (4ac986f4db).
  • B (PRRT…6tkpec): busy/unproven/winner-attach terminal exits returned without joining the exact child, so its pid-named marker survived until a later sweep → discardExitedLaunchRecord joins then removes only that child's file (15531bca62). Regressions create markers at the real pre-lock producer seam; red at prior head (residue files observed), green at head.
  • C (PRRT…6tkpef): narrating module headers removed (79ce077759).
  • r2 items: producer lifetime claim against PID reuse (9c70aa9582, red/green proved); cast removal (76316fd71e); daemon-runtime split to 963 lines (250b3125d5); iOS preflight --debug cold-startup evidence instrumentation (62a09bd298).

Validation: pnpm check:affected --run --head 62a09bd298 passed, exactCommit=true, checkoutChanged=false — /Users/thymikee/.t3/worktrees/agent-device/t3-643f2374/.tmp/validation/run-PGhlAU/receipt.json. Leak integrations smoke-daemon-http/clean/daemon-replace-exit-flush pass; focused daemon suites 3320 pass.

Threads: peV/pec/pev fixed+resolved; marker-unavailable j3DD stays open — stall retirement still applies when the marker channel is unobservable; no exemption claimed.

Pending: full CI at this head (Integration Tests, Coverage, native, device lanes are GitHub-owned), and the independent review of a2c6..62a09. Not ready for merge.

@thymikee

Copy link
Copy Markdown
Member Author

Coverage failed at 62a09bd298 because three eager-import budget tests failed. The raw job log identifies one new static dependency: daemon-runtime.ts → daemon-startup-diagnostics.ts. Both daemon entry points exceed their merge-base budgets by one module.

This is a PR regression, not a coverage-percentage failure or a known flake. The active repair worker will reproduce the tests with the same CI base and correct the import boundary. The budgets and assertions remain unchanged. I did not request a rerun. The PR remains not ready.

thymikee added a commit that referenced this pull request Oct 17, 2026
registeredDaemonFixtureArgs rewrote one shared dist/src/internal/
daemon.js per construction, so a second build could replace the script
before an earlier launched child loaded it, silently applying the wrong
per-child marker switches and registration fields. Each construction
now writes a unique fixture-entries/<uuid>/dist/src/internal/daemon.js:
the per-launch directory keeps the daemon entry path shape the
retirement identity checks require, and cleanup still removes the state
dir. A regression test builds both arg sets before launching either
child and proves child 1 executes its own switches while child 2 exits
busy: red on the shared path, green per-launch.

Fixes review thread PRRT_kwDORFGHaM6tk7ed on #3791.
thymikee added a commit that referenced this pull request Oct 17, 2026
The split moved the helpers next to the runtime but left a static edge,
so both daemon entries evaluated one more module and 12-15 more
top-level statements than the merge-base (Coverage lane red: 3
eager-closure-budgets failures at OUTPUT_ECONOMY_BASE=4a8abed7b9).
startDaemonRuntime now loads them through a function-scoped await
import at its first use point, the loading shape the gate prescribes;
composition, publication, flush, and shutdown order are unchanged and
the source-order guards still read them from this module.

Red at 62a09bd (3 failures), green here: 846/846.

Addresses the eager-closure failure reported at 62a09bd on #3791.
@thymikee

Copy link
Copy Markdown
Member Author

Repairs at head 82c813e4c4 (9 commits on a2c6e74f53; 2 new since 62a09bd298).

Causes & fixes

  • Fixture entry race (PRRT…6tk7ed): every registeredDaemonFixtureArgs call rewrote one shared entry script; a second build could replace it before an earlier child loaded. Now one fixture-entries/<uuid>/dist/src/internal/daemon.js per launch (21f333d8f1). Regression builds both arg sets before either launch, orders children through acquisition, and checks each child's own markers: red shared-path, green per-launch.
  • Coverage red at 62a09bd298: the split left a static edge into daemon-startup-diagnostics.ts. startDaemonRuntime now loads it via function-scoped await import (82c813e4c4). OUTPUT_ECONOMY_BASE=4a8abed7b9 pnpm exec vitest run --project unit-core scripts/__tests__/eager-closure-budgets.test.ts: 3 failures at 62a09bd298, 846/846 green at head. Local check:affected does not select that script lane; Coverage remains GitHub-authoritative.

Validation: check:affected --run --head 82c813e4c4 passed, exactCommit=true, checkoutChanged=false — .tmp/validation/run-Sa8E0d/receipt.json. Leak integrations pass; daemon suites 5508; budgets 846.

Threads: tk7ed fixed+resolved; peV/pec/pef fixed (replies at 4ac986f4db/15531bca62/79ce077759); marker-unavailable j3DD stays open — stall retirement still applies when the marker channel is unobservable.

Pending: CI at this head (Coverage/Integration/native/device GitHub-owned) and independent review of the two new commits. Not ready for merge.

@thymikee

Copy link
Copy Markdown
Member Author

At 62a09bd, one defect remains in the startup wait. When another live daemon holds the lock and has not published yet, the client waits to the ceiling and reports the wrong reason. In waitForDaemonStartup the loop ends with return { kind: 'timeout' } (https://github.com/callstack/agent-device/blob/62a09bd/src/daemon-client/daemon-client-startup.ts#L550) because observeContendingDaemon returns null on every pass. attemptLocalDaemonStartup maps 'timeout' to 'startup-exited', and startLocalDaemon then picks the generic startup hint instead of the overload hint. Two clients on one state dir (CLI plus MCP, parallel CI steps) or an orphaned slow daemon on an overloaded host hit this: the loser fails at 120 s with details.reason 'startup-exited' and a hint that does not name AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS. That is the #3342 case, and callers branch on details.reason. The rule: details.reason names what the client ran out of. 'startup-exited' applies only when the launch exited and no live startup is left to wait for. Reaching the ceiling while any live startup is pending, this client's own or the lock holder's, is 'startup-ceiling' with the progress hint. Could you remove the 'timeout' wait kind so this unlabeled state cannot exist? For example, return 'ceiling' when the deadline passes while a live lock owner exists, and 'early_exit' otherwise. Please add a test where the contender exits busy, the winner defers publication past a 2 s env ceiling, and the result is 'startup-ceiling' with startupTimeoutMs 2000. It should fail on this head.

Not blocking, and you can take or leave each: the production marker emitters in src/daemon.ts and daemon-runtime.ts have no test, so deleting every recordStartupPhase call keeps the suite green (one assertion through a real daemon entry, such as smoke-daemon-http, would cover it); CHANGELOG.md has no Unreleased entry for the new env var, the 120 s default, the 60 s stall window and the three reasons, as #3783 and #3780 had for similar changes; src/daemon.ts resolves the phases path twice in a row and keeps issue-number narration in comments, and the marker protocol might fit better in a sibling module such as packages/daemon-contracts/src/daemon-startup-phases.ts than in the 626-line daemon-registration-owner.ts.

Coverage failed at 62a09bd, likely because daemon-startup-diagnostics.ts added a static import of daemon-runtime.ts to the src/daemon.ts eager closure. You pushed 82c813e after this review. Its two commits move that edge out of the eager closure and give each fixture launch its own entry script, so they look aimed at the Coverage failure and the second thread below. They do not touch daemon-client-startup.ts, so the startup-wait defect above still stands. The next review will cover them.

On the open threads: the two cubic-dev-ai P2 threads do not apply, so please resolve them. The first is #3791 (comment): reportFailure already writes the failure to stderr and the daemon.log tail ships in the failure details, and a state dir that refuses a per-pid append would also fail the lock and daemon.json writes. The second is #3791 (comment): the fixture overwrite is real, but every caller that passes fixture options spawns only after the earlier child has written registration-held, so no caller races it.

Before merge, please make every wait that reaches the ceiling with a live startup pending report 'startup-ceiling' and add the failing-on-head test. Then get Coverage green at the head, and attach the measurement #3782 rule 3 asks for. That is the 'Preflight iOS runner through public CLI' step log from a run at this head, after pnpm clean:daemon and with --debug. It must show the daemon_startup diagnostic durationMs for a fresh managed start. A runner or step duration is not enough. No device run is needed beyond that.

thymikee added a commit that referenced this pull request Oct 17, 2026
The prepare step's daemon_startup diagnostic is not a cold-startup
measurement: the CLI's ensured transport finishes ensureDaemon before
sendToDaemon's timer wraps the attach connect, so the timer only sees
an attach. Time the public ensureDaemon() directly after clean:daemon
and record phase, durationMs, started, and pid, so the record counts
as cold only when started is true. The prepare command keeps its
original semantics and attaches to the same daemon.

Corrects the measurement assumption behind 62a09bd on #3791; the
cold-startup CI evidence for #3782 is still missing until a run
reports this record with started:true.
@thymikee

Copy link
Copy Markdown
Member Author

CI measurement correction at head bef883fbc1 (one commit on 82c813e4c4).

Cause: the prepare step's daemon_startup timer is not a cold-start measurement. The CLI's default transport (createEnsuredDaemonTransport) awaits public ensureDaemon() before sendToDaemon wraps its attach-only connectToDaemon, so the timer saw a 12 ms attach of an already-running daemon (job 117516177348). Prior 29–34 s figure was also not daemon startup.

Fix: after pnpm clean:daemon, one inline Node script times the public SDK ensureDaemon({stateDir}) from ./dist/src/index.js and logs {phase:'daemon_cold_startup',durationMs,started,pid} — no token/connection payload. started:true marks it cold. Removed the misleading --debug prepare flag; prepare command restored to original semantics and attaches as before.

Local proof (isolated temp state dir): cold run {"durationMs":216,"started":true,"pid":8023}, second run started:false same pid; then stopped only that daemon via state-dir-scoped pnpm clean:daemon.

Validation: check:affected --run --head bef883fbc1 passed, exactCommit=true, checkoutChanged=false — .tmp/validation/run-CeVewa/receipt.json.

Pending: one iOS CI run here reporting started:true gives the first real cold timer for the 120 s default. j3DD stays open. Not ready for merge.

@thymikee

Copy link
Copy Markdown
Member Author

Independent review found no actionable findings in 62a09bd298..82c813e4c4. It verified both fixes: per-launch fixture scripts preserve child identity and cleanup, and the diagnostics helper loads at its owning function boundary. The reviewer accepted the design and assessed smaller alternatives. It ran no additional tests.

I reviewed the workflow-only change 82c813e4c4..bef883fbc1. It times the public SDK ensureDaemon() before the CLI attaches. The record contains durationMs, started, and pid; it contains no credentials. No actionable finding.

I verified receipt run-CeVewa at bef883fbc1: passed, exactCommit=true, checkoutChanged=false. Current-head CI is still running. The cold CI timer is pending. The marker-channel objection remains open for the maintainer. The PR is not ready yet.

@thymikee

Copy link
Copy Markdown
Member Author

Required checks pass at bef883fbc13e34ad3e8ad927524faf936a755342. Coverage and Integration Tests pass. The branch is CLEAN and MERGEABLE. I read issue comments, reviews, and all inline review threads. All inline threads are resolved.

The raw iOS job log records a fresh public SDK start after pnpm clean:daemon:

{"phase":"daemon_cold_startup","durationMs":324,"started":true,"pid":60517}

This is one successful CI sample. It does not establish the maximum startup time on overloaded hosts. The CLI diagnostic measured an attach after startup, so it cannot supply this cold-start evidence.

The maintainer review identifies one remaining defect. If our contender exits busy while a verified live winner still starts, the deadline can produce startup-exited. It must produce startup-ceiling. The Apex worker owns that repair and a regression test. It must preserve the winner and remove only our exited child's marker.

The PR remains not ready until the repair, independent review, exact-head gate, and new-head CI pass. No merge requested.

@thymikee

thymikee commented Oct 18, 2026 •

Copy link
Copy Markdown
Member Author

Repair: live startup at the ceiling reports startup-ceiling (f45bc9d)

Cause. With a live lock holder deferring publication, waitForDaemonStartup ended on an unlabeled timeout result that attemptLocalDaemonStartup mapped to startup-exited with the generic hint.

Fix. Removed the timeout wait kind. A deadline passing while any startup is live — own launch (isProcessAlive) or a verified live lock owner (isLiveLockOwner: held, not provably dead/reused) — returns ceiling. A gone child with an unjoined monitor maps to startup-exited. The winner is never signaled; the exited own child is joined and only its own marker is removed.

Red→green. New sendToDaemon-boundary test (busy loser, deferred winner, 2000 ms env ceiling): red at bef883fbc1 (expected startup-ceiling, received startup-exited), green at f45bc9d470. A companion test pins gone-child-at-deadline as startup-exited.

Validation. check:affected --run --head f45bc9d470 passed, exactCommit=true, checkoutChanged=false (.tmp/validation/run-Hh9oLH). Focused budget/race/protocol/fixture and registration suites pass; leak integrations pass; eager budgets 846/846 at OUTPUT_ECONOMY_BASE=4a8abed7b9. Gross diff 2498 (<2500) at base and origin/main. Added the Unreleased changelog entry and corrected its ceiling wording: the client retires its own launch, never a live contender.

Pending: CI at f45bc9d470. Cold-startup evidence stays attributed to bef883fbc1 (324 ms, started:true).

@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 12 files (changes from recent commits).

You’re at about 99% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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

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

Comment thread src/daemon-client/__tests__/daemon-client-startup-budget.test.ts
Comment thread CHANGELOG.md Outdated
Comment thread src/daemon-client/daemon-client-startup.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

The code now looks good at 8ccb661, and both findings from the earlier review (#3791 (comment)) are fixed. CI shows 20 checks with none failing, and I know of no conflicts. I did not run the suite myself. Could you also show a cold start that goes past 60 s between recorded phases and still succeeds, with the phase markers in the output, so we know the progress-aware budget works on the slow path? The CI cold-start log from the iOS job covers only the fast path.

Not blocking: the headline test in daemon-client-startup-budget.test.ts only rewrites lock-acquired. The reader keeps the earliest timestamp per phase, so those writes never advance progress, and the run covers only 20 s against the 60 s stall window. It would pass even if the client never read markers. One case that writes successive phases (modules-loaded, composition-loaded, lock-acquired, recovery-finished) less than 60 s apart, runs past 60 s of virtual time, and sets a ceiling above that span would prove the rule. Please also fix or drop the "real markers hold off the stall" comment. You can take or leave this.

Could the startup progress records live in their own daemon-contracts module, such as daemon-startup-phases.ts beside daemon-shutdown-report.ts? They have their own file, record format, writer, reader and sweep, so registration acquisition would import only the sweep. That would drop the 140 lines added to daemon-registration-owner.ts and the suffix-named daemon-registration-owner-startup-markers.test.ts. Could src/daemon.ts also get its phases path from the same host-env/cwd resolution that startDaemonRuntime uses, for example through the baseDir input? Today it calls resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR) a second time at module top level.

The two earlier inline threads on the AGENT_DEVICE_STATE_DIR/INVALID_ARGS docs (#3791 (comment)) and the dead-pid sweep comment (#3791 (comment)) no longer apply. The first is fixed in aeb4f35 and the second now names the sweep at :137 and :229, so you can resolve both.

thymikee and others added 21 commits October 18, 2026 15:11
A client killed a healthy daemon that was still starting: the local
startup used one 15 s wall-clock deadline, and a timeout never retried.
On an overloaded host that is a failed run before any work starts
(#3782, failure class #3342).

- startLocalDaemon now waits while the spawned daemon stays alive, up to
  a 120 s ceiling; AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS overrides it
  for the CLI, MCP, and Node client alike.
- The daemon records startup phase markers (modules-loaded,
  lock-acquired, listening, registered) with timestamps in
  daemon-startup-phases.jsonl beside its registration. A live daemon
  that publishes no marker for 60 s is retired as stalled; one that
  keeps progressing is waited for to the ceiling.
- Failure details carry reason (startup-exited, startup-stalled,
  startup-ceiling), the observed phase timings, and the effective
  startupTimeoutMs. Each reason has its own hint.
- help commands documents the variable and the three reasons.
…ts owner

The integration leak oracle failed because every daemon appended its
startup phase markers to one shared `daemon-startup-phases.jsonl` beside
the registration and nothing retired that file, so any completed launch
left state-dir residue the strict oracle names as a stray entry.

Ownership now follows the file name: each launch writes only to
`daemon-startup-phases-<pid>.jsonl` through
`resolveDaemonStartupPhasesPath(stateDir, pid)`. The writing daemon
removes its file once it has registered; the launching client removes it
once that process's exit is joined (in `retireStartupAttempt`, after the
exit is observed, never before); and each new registration-lock owner
sweeps the files of provably dead pids next to the shutdown-report
removal. A live pid's file is always kept, so a concurrent or replaced
launch can neither add to, hide behind, nor delete another launch's
evidence, and the client no longer needs `spawnedAtMs` to attribute
markers to its own launch.

Along the protocol boundary:

- `recordDaemonStartupMarker` takes an optional `reportFailure` callback
  so producers surface a refused append on stderr (the daemon log tail
  the waiting client already ships as failure evidence) instead of
  swallowing it; the append stays non-fatal and a crash-truncated final
  line is separated so it cannot glue two records together.
- The phase list gains real completed boundaries, `composition-loaded`
  and `recovery-finished`, and no heartbeats, so silence still means a
  stalled startup and progress extends the stall window exactly as far
  as the work took.
- The launch-exit join drops its redundant `.catch(() => {})`: the
  `runCmdDetachedMonitored` owner resolves the promise on error and
  exit, so it never rejects.
- Startup code splits out of `daemon-client-lifecycle.ts` into
  `daemon-client-startup.ts` (budget, wait loop, marker refresh, hints),
  keeping both modules under the 1000-line tripwire; importers attach
  and ensure point at the owning module directly.

Regression coverage: the budget suite now pins per-launch attribution,
including a fresh daemon producing its own markers while an earlier
launch's leftover file is swept at the new registration, and a new
protocol suite pins the per-pid paths, the dead-pid sweep, removal
idempotence, and the write-failure seam.

Refs #3782, #3791
The documented reasons overclaimed. `startup-ceiling` said "progressing,
not registered before the ceiling", but the ceiling is reached by a
daemon that is merely alive: a progressing one is only ever stopped by a
ceiling above the stall window, so the promise was unverifiable from the
client's evidence. `startup-stalled` named a 60 s window without saying
that `AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS` does not change it, so
raising the ceiling looked like a fix for silence. Both the CLI help and
the configuration table now describe the two reasons as what they are,
and the env var is specified as a positive integer number of
milliseconds.

Refs #3791
The lifecycle and startup module headers only narrate each file's role,
which AGENTS.md reserves for public APIs, tool directives, and
constraints code cannot show. The split is owned by the imports and the
file names.

Fixes review thread PRRT_kwDORFGHaM6tkpef on #3791.
…or it

The shared emit-startup-markers flag file applied to every later fixture
in the same state dir, so the stale-history cleaner inherited the
request, wrote its own live marker, and the sweep assertion raced it.
The request is now a typed per-child option bound into that child's
entry script, and readiness waits use a per-pid file so a stale
registration-held from an earlier child cannot shortcut the await.

Fixes review thread PRRT_kwDORFGHaM6tkpeV on #3791.
… exit

A launch that exited busy, unproven, or lost the registration to a
contending winner returned its retry, error, or winner handoff without
joining its exact exited child, so the child's pid-named phase file sat
in the state dir until some later registration sweep. Every terminal
child outcome now joins the child first and removes only that child's
own file, the same ownership rule a retired startup follows; the
current winner's registration and record are untouched.

Regression tests write markers at the real pre-acquisition producer
seam (new emitStartupMarkerBeforeAcquisition fixture option) and pin
the residue rule on the retry, winner-attach, and unproven-exit paths:
red at the prior head, green with the fix.

Fixes review thread PRRT_kwDORFGHaM6tkpec on #3791.
The per-pid file names its writer, not its lifetime: if client and
daemon of an old launch both died before either cleanup ran and the
host reused the pid, the old record survived under the new writer's
name, the sweep skipped it as live, and the earliest-per-phase reader
mixed two lifetimes into one observation. The producer now claims its
own record (claimDaemonStartupPhasesFile) before its first marker, so
stale same-pid content can neither mask nor extend the fresh startup's
progress. A refused claim follows the marker contract: reported, never
fatal. The test child repeats the real producer sequence against its
own pid, which no outside test can arrange.

Addresses review finding R2-1 on #3791.
…n out of the runtime

Code ownership allows no exception: behavior was added to a module
over 1000 lines, so the module is split at its existing boundary first.
flushDaemonStartupDiagnostics, emitDaemonDiagnostic,
finishDaemonRegistration, and armDaemonMetadataLossWatch form the
daemon-log diagnostic and registration-completion edge the runtime
already treats as one unit; they move with their callers updated, no
behavior change. daemon-runtime.ts is back under the limit.
describeStartupPhaseTimings builds a Record<string, number> that always
starts at spawned: 0; the intersection cast asserted what the literal
already showed. No caller branched on it.

Addresses review finding R2-2 on #3791.
The #3782 startup-budget evidence needs one measured successful cold
startup: after clean:daemon, the prepare command's daemon_startup
diagnostic now wraps a fresh managed startup and lands in the step log.
Behavior and JSON output are unchanged.

Records the evidence obligation for #3791.
registeredDaemonFixtureArgs rewrote one shared dist/src/internal/
daemon.js per construction, so a second build could replace the script
before an earlier launched child loaded it, silently applying the wrong
per-child marker switches and registration fields. Each construction
now writes a unique fixture-entries/<uuid>/dist/src/internal/daemon.js:
the per-launch directory keeps the daemon entry path shape the
retirement identity checks require, and cleanup still removes the state
dir. A regression test builds both arg sets before launching either
child and proves child 1 executes its own switches while child 2 exits
busy: red on the shared path, green per-launch.

Fixes review thread PRRT_kwDORFGHaM6tk7ed on #3791.
The split moved the helpers next to the runtime but left a static edge,
so both daemon entries evaluated one more module and 12-15 more
top-level statements than the merge-base (Coverage lane red: 3
eager-closure-budgets failures at OUTPUT_ECONOMY_BASE=4a8abed7b9).
startDaemonRuntime now loads them through a function-scoped await
import at its first use point, the loading shape the gate prescribes;
composition, publication, flush, and shutdown order are unchanged and
the source-order guards still read them from this module.

Red at 62a09bd (3 failures), green here: 846/846.

Addresses the eager-closure failure reported at 62a09bd on #3791.
The prepare step's daemon_startup diagnostic is not a cold-startup
measurement: the CLI's ensured transport finishes ensureDaemon before
sendToDaemon's timer wraps the attach connect, so the timer only sees
an attach. Time the public ensureDaemon() directly after clean:daemon
and record phase, durationMs, started, and pid, so the record counts
as cold only when started is true. The prepare command keeps its
original semantics and attaches to the same daemon.

Corrects the measurement assumption behind 62a09bd on #3791; the
cold-startup CI evidence for #3782 is still missing until a run
reports this record with started:true.
…at the deadline

When the launched contender exited busy while another live daemon held
the registration and deferred publication, the wait loop ended in an
unlabeled 'timeout' result that mapped to startup-exited with the
generic hint. Any deadline with a live startup still pending, this
client's launch or a verified live lock owner, now reports
startup-ceiling with the effective ceiling and the overload hint;
startup-exited stays reserved for a real exit or no live startup.
Remove the 'timeout' wait kind so the unlabeled state cannot exist and
add the sendToDaemon-boundary regressions for both outcomes.

Also compress this PR's narration and duplicate test setup to keep the
gross diff within one coherent change, and add the Unreleased changelog
entry for the startup budget, the stall window, and the three reasons.
…nd pid identity

At an expired deadline, a delivered child exit is authoritative for the
lifetime it watched, so it outranks an unobserved liveness probe of a pid
that may have been reused. Unobserved, the launch's recorded birth time
must prove the pid changed hands before it counts as a live startup; an
unavailable identity stays fail-closed. Extracted classifyExpiredDaemonStartup
keeps the wait loop within its complexity thresholds. The budget test pins
liveness of its fabricated pids at the process seam instead of trusting the
host, and the winner-sweep race coverage is deduplicated into the joined
contender test.
…ore the marker sweep owner

Both facts were stated incompletely against the code:

- resolveDaemonStartupTimeoutMs runs only inside startLocalDaemon, so
  AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS is validated only when a client
  must start a daemon. A client that attaches to or reuses a running
  daemon never reads the variable. CHANGELOG, cli-help, and the
  configuration guide now say so; the guide already named INVALID_ARGS.
- The startup-marker ownership comment dropped the registration owner's
  sweep of provably dead pids, which sweepDeadDaemonStartupPhasesFiles
  still performs at each new registration lock acquisition.

Addresses review threads r4305110030 and r4305299946 on #3791.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The startup split moved observeLocalDaemon out of daemon-client-lifecycle.ts,
so the host test's lifecycle-module mock no longer stopped the ensure path
and the CLI-run case fell through to the real observation. Mock the seam the
subject imports it from now. Drop two narration comments the assertions
already state.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The per-pid startup phase file has its own record format, writer, reader
and dead-pid sweep, so it no longer belongs inside the registration
owner the review pointed at (#3791). The declarations move verbatim into
daemon-contracts/daemon-startup-phases.ts beside daemon-shutdown-report
and the suffix-named markers test follows them as
daemon-startup-phases.test.ts. Registration acquisition imports only
sweepDeadDaemonStartupPhasesFiles; the runtime, the daemon entry, the
client wait and the fixture import the record owner directly, with no
re-export shim (unreleased surface).

The daemon entry must write its earliest modules-loaded marker at module
load, so the eager-closure gate gets one APPROVED_CLOSURE_ADDITIONS row
naming the new module; the entry pays for the record it writes first.

Addresses review 6248815887 item 1 on #3791.

Refs #3782, #3791
Importers attach to daemon-contracts/daemon-startup-phases directly;
daemon-registration-owner keeps only the sweep call at acquisition.
src/daemon.ts captures its host environment once and resolves the phases
path from the same env and cwd startDaemonRuntime resolves its state dir
from, so a relative AGENT_DEVICE_STATE_DIR cannot land the earliest
markers beside a different directory than the registration (the module
top-level second resolveDaemonPaths(process.env) is gone), and the
modules-loaded marker stays the first thing recorded.

Addresses review 6248815887 items 1 and 2 on #3791.

Refs #3782, #3791
The headline slow-start test rewrote lock-acquired repeatedly; the
reader keeps each phase's earliest timestamp, so those writes never
advanced progress and the run spanned only 20 s against the 60 s stall
window. It now schedules four distinct phases (modules-loaded,
composition-loaded, lock-acquired, recovery-finished) across 70 s of
virtual time under a 150 s ceiling and asserts the wait survived on
observed successive progress. With the marker read removed, the same
scenario fails as startup-stalled at the 60 s window (verified with a
throwaway stub, not committed), so the test is credibly red without the
progress rule.

Addresses review 6248815887 item 3 on #3791.

Refs #3782, #3791
@thymikee
thymikee force-pushed the fix/daemon-startup-budget branch from 8ccb661 to d22edfe Compare October 18, 2026 13:27
@thymikee

Copy link
Copy Markdown
Member Author

Round-8 repair pushed at d22edfef3c (rebased onto 0fb25c474a; 17 prior commits replayed, 5 conflict-adapted to #3795/#3796, 4 repair commits added).

Review responses: (1) phase records moved to packages/daemon-contracts/src/daemon-startup-phases.ts; registration acquisition imports only the sweep; test renamed to daemon-startup-phases.test.ts; no compat shim. (2) src/daemon.ts captures the host environment once and resolves both the phases path and the runtime from it, preserving the earliest modules-loaded marker. (3) Headline test rewritten: four successive phases 20 s apart across 80 s of virtual time under a 150 s ceiling, past the 60 s stall window; the misleading comment is gone. Counterfactual: with the client's marker reader disabled the test fails with a startup failure, so it is not vacuous. (4) A gap past 60 s between phases stalling is the intended contract — the stall window measures absence of progress. Real slow-host evidence beyond the CI 330 ms fast-path sample is not reproducible here; the virtual-clock case is a unit proof, labeled as such. (5) INVALID_ARGS positive-integer docs on all three surfaces are unchanged.

Both prior inline threads remain resolved. Exact-head gate run-YhAWSp: exactCommit=true, checkoutChanged=false. CI pending.

@thymikee

Copy link
Copy Markdown
Member Author

Rebased and pushed at d22edfef3c, onto 0fb25c474a. Main's startup ownership and merged fact corrections are retained. Independent review is active; CI must run again.

The review follow-up separates startup records into daemon-startup-phases.ts. Registration imports its sweep. The executable resolves markers from the same captured environment and cwd as the runtime.

The revised test advances four distinct phases across 80 seconds of virtual time. Disabling marker reads makes it fail. A gap beyond 60 seconds without progress intentionally stalls. This unit result does not prove a real slow-host cold start. The prior 330 ms CI sample remains evidence only for 8ccb661.

The exact-commit gate passed (run-YhAWSp, unchanged checkout): 16,065 related tests passed, one skipped. The first run failed in the unrelated HarmonyOS toolchain test because it read host SDK settings. The passing run unset those three host SDK variables. The independent reviewer will verify that attribution.

Gross diff: 1,827 added and 673 removed lines across 22 files. The PR is not ready yet.

@thymikee

Copy link
Copy Markdown
Member Author

Independent review at d22edfef3c found no blocking code defect. It verified all 17 replayed commits: 12 unchanged and five adapted to main's startup ownership. The smaller-design assessment accepts the extracted owners and the single eager module addition.

I accepted two low-severity corrections:

  • The help and configuration text must cover a live competing startup that reaches the ceiling without observed progress.
  • The existing stall test must unset the override to prove the 120-second default. Its explicit 120000 override does not test that default.

The worker will also remove one introduced blank line in the changelog. One small repair round is active.

The reviewer independently ran the 80-second virtual test and made it fail with marker reads disabled. It also reproduced the HarmonyOS test's host-environment failure and its pass with the three SDK variables unset. The relevant files match the base.

Real slow-host cold-start evidence remains unavailable. The virtual test is unit evidence. CI and review of the repaired head remain required.

…prove the default ceiling

The help and configuration docs claimed only a progressing daemon reaches a
ceiling above the stall window. On the contended path this client's launch
exits busy and the wait runs on the exit branch, where the stall check never
runs, so a live startup owned by another client reaches the ceiling with no
observed progress. Qualify the clause to this client's own launch, matching
the CHANGELOG definition.

The no-marker budget test stubbed 120000 instead of unsetting the variable,
so it never proved the default. Unset it (stubEnv with undefined) so the
resolved ceiling is the unoverridden default even on a host that exports the
variable. Also drop a stray blank line in the Unreleased list.
@thymikee

thymikee commented Oct 18, 2026 •

Copy link
Copy Markdown
Member Author

Round-8 fixes at 0295ee5f23 (one commit, same 4-file surfaces, gross unchanged at 2,500):

  1. Ceiling prose (F1). cli-help.ts and configuration.md no longer claim only a progressing daemon reaches a ceiling above the stall window. Both now match the CHANGELOG definition: startup-ceiling means a startup was still live when the ceiling passed — this client's daemon or another client's; the client stops its own launch and never signals another client's live daemon. The outlast-the-stall-window clause is scoped to the client's own launch, so the contended busy-exit path (no stall check, ceiling with zero observed progress) is no longer misdescribed. No policy change: default 120 s, fixed 60 s window, INVALID_ARGS before spawn, reuse ignores the variable.
  2. Default-ceiling proof (F2). The no-marker test now calls vi.stubEnv('AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS', undefined), which deletes the variable even when the host exports it, so the existing startupTimeoutMs === 120_000 assertion exercises the real default resolver. Counterfactual: with DAEMON_STARTUP_CEILING_MS set to 110_000 the test fails 110000 !== 120000.
  3. Nit (N1). Stray blank line removed from the Unreleased list.

Gate: pnpm check:affected --run --head 0295ee5f23 passed, exactCommit=true, checkoutChanged=false (receipt run-Q4YXDq; host HarmonyOS SDK vars unset as before). The >60 s cold-host boundary remains unit-proven only; no live claim.

@thymikee

Copy link
Copy Markdown
Member Author

Independent review is complete at 0295ee5f23. No findings remain.

I compared the four-file repair with reviewed head d22edfef3c and the actual startup paths. Help and configuration now include the live competing startup. The default test unsets the override and retains its 120000 assertion. The changelog change removes only the blank line.

The prior independent review verified the rebase, owner extraction, host environment, smaller design, and 80-second virtual progress test. Those code paths are unchanged by this repair. The default test also fails when the default changes to 110000 and passes with a conflicting host override.

Receipt run-Q4YXDq passed at this exact commit with an unchanged checkout. The known host SDK variables were unset, as recorded and independently verified in the prior review. No inline threads are open.

CI at this head is still required. Real slow-host cold-start evidence remains unavailable; the 80-second result is unit evidence. The 60-second window without progress is unchanged.

This branch has not been deployed

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

Daemon startup: wait for a live daemon instead of a fixed 15 s deadline on slow or overloaded hosts

1 participant