Skip to content

refactor(daemon): move the runtime dependency construction to the process root (PR 6) - #3431

Open
thymikee wants to merge 3 commits into
mainfrom
refactor/daemon-composition-root
Open

thymikee wants to merge 3 commits into
mainfrom
refactor/daemon-composition-root

Conversation

@thymikee

@thymikee thymikee commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Summary

PR 6 of the daemon extraction (#3276 umbrella): the daemon server no longer builds its own dependencies. startDaemonRuntime takes { env, sessionStore, deps: DaemonRuntimeDeps }; everything that names a platform runtime, provider runtime, interactor, host diagnostics, credential fingerprinting, resource cleanup, lifecycle owners, or the platform-services / claim-recovery compositions moves to the new root composition module src/daemon-composition.ts, which src/daemon.ts calls and injects. The operational server keeps startup sequencing, listeners, shutdown, metadata watch, idle reap, and request-handler creation, with the same order on both paths. DaemonProviderCredentials (type only) lands in @agent-device/daemon-contracts/provider-credentials — the #2559 daemon⇄client shared home beside daemon-policy-file.ts; contracts was rejected under R18 (the fingerprint implementation keeps reading the plugin manifest in root, and the type is daemon⇄client vocabulary, not app-domain vocabulary). Runtime tests boot through one shared fixture (src/daemon/__tests__/daemon-runtime-fixture.ts). R76 loses its 8 daemon-runtime rows (only the direct-ios-selector edge from #2273/#2274 remains); ADRs 0019/0022/0033 amended. 30 files.

Validation

Commit c3a81fd. Local: pnpm install --frozen-lockfile && pnpm build, pnpm format, check:quick, check:layering (320/0), check:di-seams, eager-closure budgets (802/0; src/daemon.ts closure 468→469 modules = the new composition file itself, weight 5117→5116 — containment + novelty + flat weight, the split tolerance; every base module stays eager), test-file-size-ratchet, test:integration:provider (213), test:concurrency-torture (4), check:affected --run, daemon-scope unit run (462 files / 3455 tests). pnpm depgraph: spine back-edges 0, type-only inversions 11 (unchanged). Edge query over graph.json from daemon/** nodes to non-packages/ nodes outside the zone: exactly daemon/direct-ios-selector.ts -> platform-runtime-apple-resources.ts [value]. Smoke: node bin/agent-device.mjs devices + session list against the composed daemon, clean daemon stop. Provider integration/coverage run remotely on this head.

View guided diff Turn on auto-fix

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.17 MB 5.17 MB +1.5 kB
Package (unpacked) 5.17 MB 5.17 MB +1.5 kB
Package (download) 1.55 MB 1.55 MB +903 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.3 ms 26.3 ms -0.1 ms
CLI --help 76.5 ms 76.0 ms -0.5 ms

@thymikee

Copy link
Copy Markdown
Member Author

The daemon refactor looks sound, but one test gap needs fixing before merge. At c3a81fd, 18 of 18 checks pass, and I know of no conflicts.

The test fixture at src/daemon/tests/daemon-runtime-fixture.ts:26 copies the body of the src/daemon.ts IIFE almost word for word. It repeats resolveDaemonPaths, loadDaemonPolicy, new SessionStore, the assertShutdownAllowed derivation and the three session callbacks. About 25 runtime tests now run through this copy, and no test runs the real daemon.ts assembly. Three edits to daemon.ts would leave the suite green: dropping assertShutdownAllowed, so a policy that denies device-shutdown stops being enforced; giving startDaemonRuntime a different SessionStore than the one the deps closures read; or mis-wiring openWebSessionNames. A regression in the production composition root would ship unnoticed, and "exactly as the process root assembles it" in the PR body only describes the copy. The rule: the process root and every runtime test must call one assembly function, and nothing else may build the SessionStore and deps pair. Please move the daemon.ts IIFE body into one exported function beside daemon-composition.ts, for example startComposedDaemonRuntime({ env, exit?, stdout?, stderr?, registerProcessHandlers? }). daemon.ts then only calls it and keeps its catch. The fixture then becomes a re-export or goes away. Then please run the daemon-scope unit tests to confirm they pass through that one function.

Could the pair go away entirely if the server took a factory instead of built deps? The server would keep owning paths, the policy load (with its typed stderr and exit) and the SessionStore. It would call options.composeDeps({ env, baseDir, sessionsDir, daemonPolicy, <session callbacks> }), and the root would pass createDaemonRuntimeDeps, as it already does for createCommandSurface. daemon.ts would shrink to startDaemonRuntime({ composeDeps: createDaemonRuntimeDeps }), tests would pass the same function, and the SessionStore mismatch, the second resolveDaemonPaths and the daemonPolicy round-trip could not be wrong. assertShutdownAllowed would be derived in the server, which already imports daemon-policy. This seems to fit ADR 0019 as amended here, since the server owns sequencing and the root names runtimes. Putting DaemonProviderCredentials in daemon-contracts looks right, because a src/daemon home would add a client-to-daemon type edge against #2559.

Not blocking, take or leave: notifySkippedProviderRuntimes at src/daemon-composition.ts:151 repeats emitDaemonDiagnostic, so exposing skippedProviderRuntimes as data on the deps and letting the server emit provider_runtime_skipped would keep log emission in one place. DaemonRuntimeDeps declares leaseLifecycleProvider and providerRuntimeIds twice, and the server reads some members through deps.requestRouter (lines 382, 446, 484, 703), so each fact could be declared once at the top level. The doc comment in provider-credentials.ts:8 says the client reads this vocabulary, but no daemon-client module does; the real reason is that the root fingerprint module, which the client imports, then has no type edge into src/daemon. Five test files, starting with daemon-runtime-device-claims.test.ts:1, have a platformResourceCleanup import placed above the node: imports and a stray blank line before platformCleanup:, which pnpm format should fix.

I did not run the layering, eager-closure or fallow gates, so the R76/R9 results and the 468 to 469 closure claim rest on the PR body and green CI. I also did not reproduce the live devices and session list smoke run or the provider integration run.

…cess root

startDaemonRuntime now receives its composed dependencies instead of
building them: provider runtimes, the platform gateway and inventory
gateways, request platform providers, host diagnostics, credential
fingerprinting, the platform lifecycle owners, the platform-services and
claim-recovery compositions, and the command-surface factory all move to
src/daemon-composition.ts, which src/daemon.ts composes and injects. The
operational server keeps startup sequencing, listeners, shutdown, the
metadata watch, idle reap, and request-handler creation, with the same
order on both paths. DaemonProviderCredentials moves to
@agent-device/daemon-contracts/provider-credentials, the #2559 shared
home beside daemon-policy-file; the fingerprint implementation stays in
the root. Tests boot the runtime through one shared fixture.

No production file under src/daemon/ now reads a non-package src/ module
except the direct-ios-selector edge owned by #2273/#2274.
…s as a factory

startDaemonRuntime now takes a composeDeps factory and keeps the daemon
paths, the ADR 0029 policy load (typed stderr and exit included), the
SessionStore, the session callbacks, and the assertShutdownAllowed
derivation in the server, so the process root and every runtime test
share one assembly and the second resolveDaemonPaths and the policy
round-trip go away. Skipped provider runtimes are data on the deps and
the server emits provider_runtime_skipped through emitDaemonDiagnostic;
the lease-lifecycle facts, the runtime gateway, the resource-cleanup
surface, and the claim-recovery factory are declared once at the top
level of DaemonRuntimeDeps. The fixture is a thin call through the real
factory, and a new assembly test proves a dropped shutdown gate, a
mis-wired store, or an unreadable policy cannot pass silently.
The daemon-runtime rows of the R76 inventory no longer describe tree
reality once the composition moved out of src/daemon/, so the stale
entries go and the planted tests retarget the one surviving classified
pair. daemon-composition is declared in the platform-runtime zone (a
composition, not an executable), and ADRs 0019/0022/0033 record where
the composition and the extracted credential type landed.
@thymikee
thymikee force-pushed the refactor/daemon-composition-root branch from c3a81fd to 3ebaa58 Compare October 11, 2026 17:42
@thymikee
thymikee marked this pull request as ready for review October 11, 2026 17:42
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main (7e5ed4dbd) and took the factory shape; review fixes are in 8cfa76edb (gates commit stays last at 3ebaa5817).

  • One assembly (required): startDaemonRuntime takes composeDeps and keeps the paths, the ADR 0029 policy load (typed stderr + exit 1), the SessionStore, the session callbacks, and the assertShutdownAllowed derivation. daemon.ts is the factory call plus its catch; the fixture is a thin call through the real createDaemonRuntimeDeps, so ~25 tests and the entry now run the same assembly. The second resolveDaemonPaths and the daemonPolicy round-trip are gone. Proof: src/__tests__/daemon-composition-assembly.test.ts boots the real assembly with a policy denying device-shutdown and asserts the server-derived gate throws DAEMON_POLICY_DENIED/rule: capability, the callbacks read the server's own store instance, and an unreadable policy exits 1 before the factory runs. Mutation-checked: dropping the derivation or wiring a second SessionStore both turn it red.
  • skippedProviderRuntimes: now data on the deps; the server emits provider_runtime_skipped through emitDaemonDiagnostic. notifySkippedProviderRuntimes deleted; the existing logged-after-publication test still passes.
  • Declared once: leaseLifecycleProvider/providerRuntimeIds (plus recoverExpiredLease/recoverableProviderIds, the gateway, the cleanup surface, and the claim-recovery factory) moved to the top level of DaemonRuntimeDeps; the server no longer reads them through deps.requestRouter.
  • provider-credentials doc: reason corrected to the root fingerprint module having no type edge into src/daemon.
  • Five test files: import order and stray blank lines fixed (oxfmt doesn't sort imports, so it was done by hand).

Closure stays at main's 468: the entry reaches the composition through a function-scoped await import wrapper. Gates at 3ebaa5817: format, check:quick, check:layering, check:di-seams, eager-closure (802 pass), test-file-size-ratchet, test:integration:provider (213), test:concurrency-torture, check:affected --run all green. Smoke on the built daemon: start, devices, session list, SIGTERM stop with clean metadata removal. (daemon stop's pid-identity verification fails on this host against main's build too — pre-existing, not a regression.)

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

4 issues found across 31 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/adr/0022-daemon-platform-runtime-coupling.md">

<violation number="1" location="docs/adr/0022-daemon-platform-runtime-coupling.md:14">
P3: Section 2 follows this amendment, so “above” points readers in the wrong direction. Change it to “below.”</violation>

<violation number="2" location="docs/adr/0022-daemon-platform-runtime-coupling.md:14">
P3: This R76 importer path omits the `src/` prefix, so the amended ADR points readers to a nonexistent path. Use the repository-relative path `src/daemon/direct-ios-selector.ts`.</violation>
</file>

<file name="src/__tests__/daemon-composition-assembly.test.ts">

<violation number="1" location="src/__tests__/daemon-composition-assembly.test.ts:100">
P2: The runtime is shut down only after all assertions, so a failed assertion leaves its HTTP server active and can hang the Vitest worker. Keep the controller outside `try` and shut it down in `finally`.</violation>

<violation number="2" location="src/__tests__/daemon-composition-assembly.test.ts:103">
P3: These directories come from `mkdtempForTestSync`, whose contract delegates removal to run-level TMPDIR teardown; remove both per-test `rmSync` calls.

(Based on your team's feedback about Vitest temporary-directory cleanup.)</violation>
</file>

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

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

outputPath: store.resolveAppLogPath('web-assembly'),
pidPath: store.resolveAppLogPidPath('web-assembly'),
});
await runtime?.shutdown();

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.

P2: The runtime is shut down only after all assertions, so a failed assertion leaves its HTTP server active and can hang the Vitest worker. Keep the controller outside try and shut it down in finally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/__tests__/daemon-composition-assembly.test.ts, line 100:

<comment>The runtime is shut down only after all assertions, so a failed assertion leaves its HTTP server active and can hang the Vitest worker. Keep the controller outside `try` and shut it down in `finally`.</comment>

<file context>
@@ -0,0 +1,140 @@
+      outputPath: store.resolveAppLogPath('web-assembly'),
+      pidPath: store.resolveAppLogPidPath('web-assembly'),
+    });
+    await runtime?.shutdown();
+    assert.deepEqual(exits, [0]);
+  } finally {
</file context>

hubs, and the platform-services and claim-recovery dynamic edges) moved out of `src/daemon/` into
the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it
is no longer a daemon-zone importer. The R76 inventory now carries only the leaked
selector-query edge (`daemon/direct-ios-selector.ts`); the section-2 outcomes above remain the

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.

P3: Section 2 follows this amendment, so “above” points readers in the wrong direction. Change it to “below.”

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/adr/0022-daemon-platform-runtime-coupling.md, line 14:

<comment>Section 2 follows this amendment, so “above” points readers in the wrong direction. Change it to “below.”</comment>

<file context>
@@ -6,6 +6,14 @@ Accepted (2026-09-06). Implements the audit deliverables of issue #2278. The cla
+hubs, and the platform-services and claim-recovery dynamic edges) moved out of `src/daemon/` into
+the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it
+is no longer a daemon-zone importer. The R76 inventory now carries only the leaked
+selector-query edge (`daemon/direct-ios-selector.ts`); the section-2 outcomes above remain the
+record of how each was classified.
+
</file context>
Suggested change
selector-query edge (`daemon/direct-ios-selector.ts`); the section-2 outcomes above remain the
selector-query edge (`daemon/direct-ios-selector.ts`); the section-2 outcomes below remain the

await runtime?.shutdown();
assert.deepEqual(exits, [0]);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });

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.

P3: These directories come from mkdtempForTestSync, whose contract delegates removal to run-level TMPDIR teardown; remove both per-test rmSync calls.

(Based on your team's feedback about Vitest temporary-directory cleanup.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/__tests__/daemon-composition-assembly.test.ts, line 103:

<comment>These directories come from `mkdtempForTestSync`, whose contract delegates removal to run-level TMPDIR teardown; remove both per-test `rmSync` calls.

(Based on your team's feedback about Vitest temporary-directory cleanup.) </comment>

<file context>
@@ -0,0 +1,140 @@
+    await runtime?.shutdown();
+    assert.deepEqual(exits, [0]);
+  } finally {
+    fs.rmSync(stateDir, { recursive: true, force: true });
+  }
+});
</file context>

hubs, and the platform-services and claim-recovery dynamic edges) moved out of `src/daemon/` into
the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it
is no longer a daemon-zone importer. The R76 inventory now carries only the leaked
selector-query edge (`daemon/direct-ios-selector.ts`); the section-2 outcomes above remain the

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.

P3: This R76 importer path omits the src/ prefix, so the amended ADR points readers to a nonexistent path. Use the repository-relative path src/daemon/direct-ios-selector.ts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/adr/0022-daemon-platform-runtime-coupling.md, line 14:

<comment>This R76 importer path omits the `src/` prefix, so the amended ADR points readers to a nonexistent path. Use the repository-relative path `src/daemon/direct-ios-selector.ts`.</comment>

<file context>
@@ -6,6 +6,14 @@ Accepted (2026-09-06). Implements the audit deliverables of issue #2278. The cla
+hubs, and the platform-services and claim-recovery dynamic edges) moved out of `src/daemon/` into
+the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it
+is no longer a daemon-zone importer. The R76 inventory now carries only the leaked
+selector-query edge (`daemon/direct-ios-selector.ts`); the section-2 outcomes above remain the
+record of how each was classified.
+
</file context>
Suggested change
selector-query edge (`daemon/direct-ios-selector.ts`); the section-2 outcomes above remain the
+selector-query edge (`src/daemon/direct-ios-selector.ts`); the section-2 outcomes above remain the

@thymikee

Copy link
Copy Markdown
Member Author

At 3ebaa58 I found no blocking problems, and all 19 checks pass. This follows the earlier review at c3a81fd, whose evidence request is now met. I ran no gates myself. The layering, di-seams, eager-closure (the 468-module claim), fallow, provider integration and concurrency torture results rest on your report and green CI. I did not reproduce the built-daemon smoke or the daemon stop pid-identity failure you report on main. I read the assembly test assertions to judge regression validity, but I did not run the mutations you describe.

Not blocking, take or leave: daemonPolicy in DaemonRuntimeDepsInput is passed to the factory at https://github.com/callstack/agent-device/blob/3ebaa58/src/daemon/server/daemon-runtime.ts#L169, but createDaemonRuntimeDeps never reads it. The policy reaches the composition only through assertShutdownAllowed. Dropping the field and the matching argument in the composeDeps call, and keeping the test's gate assertion on assertShutdownAllowed, would remove the dead input.

Four Cubic threads still apply. The P2 on the assembly test not shutting down its runtime in finally is at #3431 (comment). It only leaves a listener open in a failing test and production is unaffected, but please add the shutdown in finally or reply on the thread before merge. Three P3 threads also apply: #3431 (comment), #3431 (comment) and #3431 (comment).

No conflicts are known. Apart from that P2 thread, nothing blocks merge; the three P3 notes are optional.

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.

1 participant