Skip to content

perf(daemon-client): cache remote health by daemon identity - #3015

Merged
thymikee merged 10 commits into
mainfrom
codex/daemon-health-cache-2650
Sep 28, 2026
Merged

thymikee merged 10 commits into
mainfrom
codex/daemon-health-cache-2650

Conversation

@thymikee

@thymikee thymikee commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Cache successful remote /health probes by daemon URL, token, PID, and advertised instance. Subsequent commands use one RPC. The daemon and proxy refuse stale instance preconditions before dispatch; the client re-probes compatibility and retries once within the original request deadline. Authentication precedes instance refusal. Transport failures invalidate the cache; ordinary command errors do not. Legacy peers retain per-command probes. Closes #2650.

The daemon refusal lives in a focused HTTP module; http-server.ts is 995 lines. The early bad-token response preserves the prior JSON-RPC -32000 code, HTTP 401, and UNAUTHORIZED data. ADR 0006, the wire ledger, and planted-red mutations cover direct and proxied framing. Scope: 17 files, 996 changed lines.

Validation

Commit: b154c20, rebased on main 8fe03de. Exact-head pnpm check:affected --run passed: format, lint, typecheck, layering, fallow, build, 167 related files / 1,081 tests, and released wire compatibility against v0.21.16. Focused tests cover authentication order, restart refusal, bounded retry, protocol skew, proxy forwarding, and legacy peers. The new wire assertion failed on the prior head (-32001 versus -32000) and passes after the fix. The static import passes all 753 eager-closure budget cases. Restoring the old uncapped health call made the delayed-probe test fail after about one second; the fixed test passes in about 160 ms. New-head CI pending.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

5 issues found across 14 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="test/wire-compat/ledger.json">

<violation number="1" location="test/wire-compat/ledger.json:176">
P3: This block, plus `LeaseRpcCommand`, `SessionIsolationMode`, and `SessionRuntimePlatform`, was deleted from its previous position and re-added here (or re-sorted) with byte-identical digests — ~18 ledger lines that are not wire changes. This README says "every ledger line in a diff is a deliberate wire change someone chose to make," and the re-sort buries the real instance-ID digest changes. Keep the re-sort in its own commit or revert it; the moved entries carry unchanged hashes, so nothing about them needed to change.</violation>
</file>

<file name="test/wire-compat/surface.ts">

<violation number="1" location="test/wire-compat/surface.ts:74">
P2: The manifest declares the two new instance-header constants as wire surface but does not list their consumer, `readRemoteInstanceHeaders` in src/daemon-client/daemon-client-transport.ts. Per the README's "both sides of every boundary are now listed" rule, a future narrowing of that reader (e.g. dropping `upstreamInstanceId` handling, or reading a wrong header name) would silently disable instance-change detection and restart re-probing without moving any digest, which is exactly the silent-misparse class the gate exists to catch. List `readRemoteInstanceHeaders` in the CLIENT_TRANSPORT consumer block and paste its digest into the ledger (with a compatibleChanges ack, per ADR 0006 additive rule).</violation>
</file>

<file name="src/daemon-client/daemon-client-transport.ts">

<violation number="1" location="src/daemon-client/daemon-client-transport.ts:511">
P3: The identity recheck runs `readRemoteDaemonHealth` (up to REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000 ms plus network latency) inside the RPC's original request-envelope timeout, because `timeoutHandle` keeps running while `identityCheck` probes. The first command after a daemon or proxy restart can therefore be rejected as a request timeout even though the restarted daemon is reachable and its response has already arrived. Consider excluding the recheck probe from the request envelope (clear the envelope while rechecking, or give the probe its own budget) so a detected restart surfaces the response after verification instead of racing the command timeout.</violation>

<violation number="2" location="src/daemon-client/daemon-client-transport.ts:529">
P2: The progress reader clears the RPC timer before this identity recheck finishes, so a final NDJSON envelope can make a command outlive its configured `timeoutMs` while `/health` hangs. Keep the request timer active until identity verification and response handling complete.</violation>
</file>

<file name="src/daemon-client/__tests__/daemon-client-health-cache.test.ts">

<violation number="1" location="src/daemon-client/__tests__/daemon-client-health-cache.test.ts:67">
P3: This assertion matches the error by message text, which the repo convention explicitly avoids ('Key on typed reasons/details, never error text; no message sniffs' in AGENTS.md). The thrown AppError carries the typed detail `remoteRpcProtocolVersion` (and `code: 'COMMAND_FAILED'` shared with every other daemon failure), so the failure can be distinguished by details instead of the message. Same for the second `/RPC protocol is incompatible/` assertion at the `incompatible-instance` step.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/daemon-client/daemon-client-health-cache.ts Outdated
...from(
DAEMON_HTTP,
'DAEMON_HTTP_BASE_PATH',
'DAEMON_HTTP_INSTANCE_HEADER',

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The manifest declares the two new instance-header constants as wire surface but does not list their consumer, readRemoteInstanceHeaders in src/daemon-client/daemon-client-transport.ts. Per the README's "both sides of every boundary are now listed" rule, a future narrowing of that reader (e.g. dropping upstreamInstanceId handling, or reading a wrong header name) would silently disable instance-change detection and restart re-probing without moving any digest, which is exactly the silent-misparse class the gate exists to catch. List readRemoteInstanceHeaders in the CLIENT_TRANSPORT consumer block and paste its digest into the ledger (with a compatibleChanges ack, per ADR 0006 additive rule).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/wire-compat/surface.ts, line 74:

<comment>The manifest declares the two new instance-header constants as wire surface but does not list their consumer, `readRemoteInstanceHeaders` in src/daemon-client/daemon-client-transport.ts. Per the README's "both sides of every boundary are now listed" rule, a future narrowing of that reader (e.g. dropping `upstreamInstanceId` handling, or reading a wrong header name) would silently disable instance-change detection and restart re-probing without moving any digest, which is exactly the silent-misparse class the gate exists to catch. List `readRemoteInstanceHeaders` in the CLIENT_TRANSPORT consumer block and paste its digest into the ledger (with a compatibleChanges ack, per ADR 0006 additive rule).</comment>

<file context>
@@ -68,7 +68,14 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [
+      ...from(
+        DAEMON_HTTP,
+        'DAEMON_HTTP_BASE_PATH',
+        'DAEMON_HTTP_INSTANCE_HEADER',
+        'DAEMON_HTTP_UPSTREAM_INSTANCE_HEADER',
+        'buildDaemonHttpUrl',
</file context>
Fix with cubic

Comment thread src/daemon-client/daemon-client-transport.ts Outdated
reject,
}),
handleResponseBody: (body) => {
void identityCheck

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The progress reader clears the RPC timer before this identity recheck finishes, so a final NDJSON envelope can make a command outlive its configured timeoutMs while /health hangs. Keep the request timer active until identity verification and response handling complete.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon-client/daemon-client-transport.ts, line 529:

<comment>The progress reader clears the RPC timer before this identity recheck finishes, so a final NDJSON envelope can make a command outlive its configured `timeoutMs` while `/health` hangs. Keep the request timer active until identity verification and response handling complete.</comment>

<file context>
@@ -429,27 +508,49 @@ async function sendHttpRequest(
-                reject,
-              }),
+            handleResponseBody: (body) => {
+              void identityCheck
+                .then(() =>
+                  handleDaemonHttpResponseBody(body, {
</file context>
Fix with cubic

Comment thread test/wire-compat/ledger.json Outdated
"src/remote/upload-stream.ts#streamFileToHttpRequest": "sha256:ce07ea33a275e06e4cceaf0c75a079bd0c7938f36df6817407dbb1cc8c4ff900",
"src/remote/upload-stream.ts#streamFileToHttpRequestAttempt": "sha256:aa73fb51890ca5e43d4aa80c1d2124b574568380a73b19eae7da0ee3e3e7acf8"
"src/remote/upload-stream.ts#streamFileToHttpRequestAttempt": "sha256:aa73fb51890ca5e43d4aa80c1d2124b574568380a73b19eae7da0ee3e3e7acf8",
"src/request-progress-protocol.ts#DaemonProgressEnvelope": "sha256:16162d01cfc43fc6a0198dbba3358981c1a278a70f7494ebe70d051f517cfb22",

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This block, plus LeaseRpcCommand, SessionIsolationMode, and SessionRuntimePlatform, was deleted from its previous position and re-added here (or re-sorted) with byte-identical digests — ~18 ledger lines that are not wire changes. This README says "every ledger line in a diff is a deliberate wire change someone chose to make," and the re-sort buries the real instance-ID digest changes. Keep the re-sort in its own commit or revert it; the moved entries carry unchanged hashes, so nothing about them needed to change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/wire-compat/ledger.json, line 176:

<comment>This block, plus `LeaseRpcCommand`, `SessionIsolationMode`, and `SessionRuntimePlatform`, was deleted from its previous position and re-added here (or re-sorted) with byte-identical digests — ~18 ledger lines that are not wire changes. This README says "every ledger line in a diff is a deliberate wire change someone chose to make," and the re-sort buries the real instance-ID digest changes. Keep the re-sort in its own commit or revert it; the moved entries carry unchanged hashes, so nothing about them needed to change.</comment>

<file context>
@@ -178,14 +172,17 @@
     "src/remote/upload-stream.ts#streamFileToHttpRequest": "sha256:ce07ea33a275e06e4cceaf0c75a079bd0c7938f36df6817407dbb1cc8c4ff900",
-    "src/remote/upload-stream.ts#streamFileToHttpRequestAttempt": "sha256:aa73fb51890ca5e43d4aa80c1d2124b574568380a73b19eae7da0ee3e3e7acf8"
+    "src/remote/upload-stream.ts#streamFileToHttpRequestAttempt": "sha256:aa73fb51890ca5e43d4aa80c1d2124b574568380a73b19eae7da0ee3e3e7acf8",
+    "src/request-progress-protocol.ts#DaemonProgressEnvelope": "sha256:16162d01cfc43fc6a0198dbba3358981c1a278a70f7494ebe70d051f517cfb22",
+    "src/request-progress-protocol.ts#DaemonResponseEnvelope": "sha256:202ae64836af890549a1d95963903f9a506294b80bd0862c1b708e72d7f286d0",
+    "src/request-progress-protocol.ts#isDaemonProgressEnvelope": "sha256:e38ec26b64d20e257ff00860548e9c12ac22faf5a6308749d4715129814b381f",
</file context>
Fix with cubic

headers,
},
(res) => {
const identityCheck = verifyRemoteInstance(

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The identity recheck runs readRemoteDaemonHealth (up to REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000 ms plus network latency) inside the RPC's original request-envelope timeout, because timeoutHandle keeps running while identityCheck probes. The first command after a daemon or proxy restart can therefore be rejected as a request timeout even though the restarted daemon is reachable and its response has already arrived. Consider excluding the recheck probe from the request envelope (clear the envelope while rechecking, or give the probe its own budget) so a detected restart surfaces the response after verification instead of racing the command timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon-client/daemon-client-transport.ts, line 511:

<comment>The identity recheck runs `readRemoteDaemonHealth` (up to REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000 ms plus network latency) inside the RPC's original request-envelope timeout, because `timeoutHandle` keeps running while `identityCheck` probes. The first command after a daemon or proxy restart can therefore be rejected as a request timeout even though the restarted daemon is reachable and its response has already arrived. Consider excluding the recheck probe from the request envelope (clear the envelope while rechecking, or give the probe its own budget) so a detected restart surfaces the response after verification instead of racing the command timeout.</comment>

<file context>
@@ -429,27 +508,49 @@ async function sendHttpRequest(
         headers,
       },
       (res) => {
+        const identityCheck = verifyRemoteInstance(
+          info,
+          res.headers ?? {},
</file context>
Fix with cubic

Comment thread src/daemon-client/daemon-client.ts Outdated
await assert.rejects(request('second'));
failRpc = false;
rpcProtocolVersion = DAEMON_RPC_PROTOCOL_VERSION + 1;
await assert.rejects(request('second'), /RPC protocol is incompatible/);

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This assertion matches the error by message text, which the repo convention explicitly avoids ('Key on typed reasons/details, never error text; no message sniffs' in AGENTS.md). The thrown AppError carries the typed detail remoteRpcProtocolVersion (and code: 'COMMAND_FAILED' shared with every other daemon failure), so the failure can be distinguished by details instead of the message. Same for the second /RPC protocol is incompatible/ assertion at the incompatible-instance step.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon-client/__tests__/daemon-client-health-cache.test.ts, line 67:

<comment>This assertion matches the error by message text, which the repo convention explicitly avoids ('Key on typed reasons/details, never error text; no message sniffs' in AGENTS.md). The thrown AppError carries the typed detail `remoteRpcProtocolVersion` (and `code: 'COMMAND_FAILED'` shared with every other daemon failure), so the failure can be distinguished by details instead of the message. Same for the second `/RPC protocol is incompatible/` assertion at the `incompatible-instance` step.</comment>

<file context>
@@ -0,0 +1,110 @@
+    await assert.rejects(request('second'));
+    failRpc = false;
+    rpcProtocolVersion = DAEMON_RPC_PROTOCOL_VERSION + 1;
+    await assert.rejects(request('second'), /RPC protocol is incompatible/);
+    assert.deepEqual(paths.slice(5), ['POST /rpc', 'GET /health']);
+    rpcProtocolVersion = DAEMON_RPC_PROTOCOL_VERSION;
</file context>
Fix with cubic

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.88 MB 4.88 MB +3.6 kB
Package (unpacked) 4.88 MB 4.88 MB +3.6 kB
Package (download) 1.46 MB 1.46 MB +902 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.7 ms 25.7 ms -0.0 ms
CLI --help 79.5 ms 77.3 ms -2.2 ms

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

Copy link
Copy Markdown

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

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

Fix all with cubic | Re-trigger cubic

Comment thread src/daemon-client/daemon-client-transport.ts
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 71df684. With a cached health entry, one RPC can still reach an incompatible daemon after a restart on the same URL.

When the cached entry for baseUrl+token matches in matchesRemoteIdentity, the RPC is sent first. Only then does verifyRemoteInstance check /health again and throw. The daemon does not check the client protocol on the RPC, so nothing refuses the request before it runs. ADR 0006 refuses a skewed peer before dispatch. Here a mutating command (install, fill, click) can run on an incompatible daemon and then report a failure, and a retry runs it twice. Also, after a client ships with this cache, no future daemon can protect it. Please prove the cached identity on the RPC itself: send the cached instance (and the upstream instance, if any) as a request header, and have http-server.ts and daemon-proxy.ts refuse a mismatch with a typed reason before handleRequest or forwarding. The client can then clear the entry, probe again and retry once. Only peers that advertise instanceId are cached, so every cached peer can honor this.

Could that pre-dispatch check replace the post-RPC identity code? Then verifyRemoteInstance, recheckChangedRemoteInstance, hasCompleteRemoteInstanceHeaders, healthMatchesRemoteInstance, the onIdentityChange callback and the response instance headers can go, which removes most of the 197 added transport lines. What stops this? ADR 0006 would need a line that says cached peers must honor the instance check in all future protocol versions.

Not blocking: the cache in daemon-client.ts is cleared on any rejection, including normal daemon error envelopes; lease beats get no onIdentityChange; there is no test for a proxy whose upstream changes; and the wire-compat ledger was re-serialized beyond the new entries.

CI: both Smoke Tests jobs are still queued. Smoke uses the local daemon route, and the remote cache is active only when baseUrl is set. No conflicts.

@thymikee
thymikee force-pushed the codex/daemon-health-cache-2650 branch from 71df684 to 997f73f Compare September 28, 2026 14:08

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

Copy link
Copy Markdown

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

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/daemon/server/http-server.ts Outdated
Comment thread test/wire-compat/surface.ts Outdated
Comment thread src/remote/daemon-proxy.ts
Comment thread src/__tests__/daemon-proxy.test.ts
Comment thread src/daemon-client/daemon-client-transport.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 997f73f. The evidence gap from the earlier review is closed, and the code is ready for human review.

Not blocking: the daemon refuses a stale instance before the auth hook, but the proxy checks auth first. So an unauthenticated request with a stale header gets 409 from the daemon and 401 from the proxy (http-server.ts). Also, both transport tests use a hand-written 409 response as the daemon instead of a real createDaemonHttpServer (test). You can move the refusal after the auth hook and use the real server in the proxy test, or leave both.

One question: the instance check covers one proxy and its upstream. Would a chain of two proxies miss a restart of the innermost daemon? Main has the same limit, so this is only worth handling if chained proxies are supported.

CI: both Smoke Tests jobs are still queued. They run the local daemon route, which never sends the instance header, so overlap with this change is small.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 28, 2026
@thymikee
thymikee force-pushed the codex/daemon-health-cache-2650 branch 2 times, most recently from 1a291fd to 54d8598 Compare September 28, 2026 16:57
@thymikee
thymikee force-pushed the codex/daemon-health-cache-2650 branch from 54d8598 to 1a285e2 Compare September 28, 2026 18:11
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 1a285e2. The follow-up commits look correct: the stale-instance refusal now runs after authentication on both the daemon and the proxy, and the restart probe and retry stay inside the original request deadline. This is ready for human review.

Not blocking: the new token check in http-server.ts repeats the request-router.ts check and changes the JSON-RPC error code for a bad-token RPC from -32000 to -32001 (HTTP 401 and UNAUTHORIZED stay the same), so it is worth a line in the ledger rationale if intended; the dynamic import around the stale-instance refusal could be static, since it only loads modules http-server already loads; and the ledger rationale could say that every health probe is now capped by total time, not only the one near the command deadline.

The two Smoke Tests jobs are still queued. Local clients send the right token and never send the instance header, so overlap with this change is small.

@thymikee
thymikee merged commit 1433e11 into main Sep 28, 2026
18 of 20 checks passed
@thymikee
thymikee deleted the codex/daemon-health-cache-2650 branch September 28, 2026 19:27
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-28 19:27 UTC

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(daemon-client): persistent remote clients re-run the /health probe on every command

1 participant