Skip to content

fix: resolve ConfigCache.has() stat inflation and hook system errors#582

Open
oalanicolas wants to merge 2 commits intomainfrom
fix/bugfix-497-528-config-cache-hooks
Open

fix: resolve ConfigCache.has() stat inflation and hook system errors#582
oalanicolas wants to merge 2 commits intomainfrom
fix/bugfix-497-528-config-cache-hooks

Conversation

@oalanicolas
Copy link
Collaborator

@oalanicolas oalanicolas commented Mar 11, 2026

Summary

Fixes two reported bugs with correct implementations:

Related PRs

Test plan

  • All 303 test suites pass (7690 tests)
  • npm run lint — 0 errors
  • hook-entry.test.js updated to match new behavior (process.exitCode, createSession mock)
  • config-cache has() no longer inflates stats when called
  • precompact-session-digest resolves runner path in both framework-dev and installed-project modes

Closes #497
Closes #528

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Ensure sessions are initialized on first prompt to prevent silent failures.
    • Improve cache validity checks to avoid metric side effects and perform expiration cleanup.
  • Refactor

    • Make shutdown/exit flow more graceful to allow stdout flush and natural process termination.
    • Improve hook runner resolution and early-exit behavior.
  • Tests

    • Update session-related mocks and tests to validate exitCode-based termination.
  • Chores

    • Update install manifest metadata (timestamps, hashes, sizes).

…497, #528)

#497: ConfigCache.has() delegated to get(), inflating hit/miss stats.
Reimplemented has() to check Map directly without touching metrics.
Applied to both core/config/ and infrastructure/scripts/ copies.

#528: precompact-session-digest.cjs failed in installed projects because
runner path didn't account for node_modules layout. Added path resolution
with fallback candidates. Also fixed:
- synapse-engine.cjs: replaced process.exit() with process.exitCode for
  clean stdout flush
- hook-runtime.js: create session file on first prompt when loadSession
  returns null (prevents silent updateSession failures)

Updated tests to match new behavior (process.exitCode, createSession export).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aios-core Ready Ready Preview, Comment Mar 11, 2026 0:55am

Request Review

@github-actions github-actions bot added area: agents Agent system related area: workflows Workflow system related squad mcp type: test Test coverage and quality area: core Core framework (.aios-core/core/) area: installer Installer and setup (packages/installer/) area: synapse SYNAPSE context engine area: cli CLI tools (bin/, packages/aios-pro-cli/) area: pro Pro features (pro/) area: health-check Health check system area: docs Documentation (docs/) area: devops CI/CD, GitHub Actions (.github/) labels Mar 11, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 30cf69c5-4058-4c09-9f43-67c93fe85957

📥 Commits

Reviewing files that changed from the base of the PR and between 3b7c87d and 1b56576.

📒 Files selected for processing (2)
  • .aiox-core/data/entity-registry.yaml
  • .aiox-core/install-manifest.yaml

Walkthrough

ConfigCache.has() now checks expiration without calling get(); hook runner lookup for precompact-runner.js is dynamic and exits gracefully if missing; session initialization uses createSession when loadSession returns null; process exit handling switched to exitCode-based flush; manifest hashes and timestamp updated.

Changes

Cohort / File(s) Summary
ConfigCache has() implementation
aiox-core/core/config/config-cache.js, aiox-core/infrastructure/scripts/config-cache.js
Reimplemented has(key) to check presence and TTL directly, perform inline eviction on expiry, and avoid calling get() (preventing hit/miss metric side effects).
Session initialization
aiox-core/core/synapse/runtime/hook-runtime.js
resolveHookRuntime now imports createSession and will create a session when loadSession returns null but a sessionId exists; falls back to a default session object if needed.
Hook runner path resolution
.claude/hooks/precompact-session-digest.cjs
Added resolveRunnerPath to search multiple candidate locations for precompact-runner.js; exits early (no-op) if runner not found to avoid noisy errors.
Exit handling refactor
.claude/hooks/synapse-engine.cjs
Removed forced synchronous process.exit paths and safeExit wrapper; use a timeout and process.exitCode to allow stdout to flush and let Node exit naturally.
Test updates
tests/synapse/hook-entry.test.js
Test mock session-manager now exports createSession in addition to loadSession; tests adjusted to assert process.exitCode === 0 instead of spying on process.exit.
Manifest update
aiox-core/install-manifest.yaml
Updated generated_at timestamp and updated SHA256 hashes and file sizes across many entries to reflect content changes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main fixes: ConfigCache.has() stat inflation and hook system errors, matching the key objectives in the changeset.
Linked Issues check ✅ Passed All code requirements from #497 (reimplemented has() without invoking get()) and #528 (multi-path runner resolution, process.exitCode handling, createSession on null loadSession) are implemented across modified files.
Out of Scope Changes check ✅ Passed All changes are directly related to the linked issues: config-cache.js fixes #497, precompact-session-digest.cjs and synapse-engine.cjs address #528, and hook-runtime.js adds session initialization logic required by #528.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/bugfix-497-528-config-cache-hooks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 11, 2026

📊 Coverage Report

Coverage report not available

📈 Full coverage report available in Codecov


Generated by PR Automation (Story 6.1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
.aiox-core/core/config/config-cache.js (1)

74-85: Extract the expiry/eviction check into one helper.

get(), has(), and clearExpired() now all carry their own TTL logic here, and the same behavior is mirrored again in .aiox-core/infrastructure/scripts/config-cache.js. Centralizing the “is entry still valid, otherwise evict it” path would reduce drift on this core module.

♻️ Possible refactor
+  _hasLiveEntry(key, now = Date.now()) {
+    if (!this.cache.has(key)) {
+      return false;
+    }
+
+    const timestamp = this.timestamps.get(key);
+    if (now - timestamp > this.ttl) {
+      this.cache.delete(key);
+      this.timestamps.delete(key);
+      return false;
+    }
+
+    return true;
+  }
+
   get(key) {
-    if (!this.cache.has(key)) {
+    if (!this._hasLiveEntry(key)) {
       this.misses++;
       return null;
     }
-
-    const timestamp = this.timestamps.get(key);
-    const now = Date.now();
-
-    // Check if expired
-    if (now - timestamp > this.ttl) {
-      this.cache.delete(key);
-      this.timestamps.delete(key);
-      this.misses++;
-      return null;
-    }
 
     this.hits++;
     return this.cache.get(key);
   }
 
   has(key) {
-    if (!this.cache.has(key)) {
-      return false;
-    }
-    const timestamp = this.timestamps.get(key);
-    if (Date.now() - timestamp > this.ttl) {
-      this.cache.delete(key);
-      this.timestamps.delete(key);
-      return false;
-    }
-    return true;
+    return this._hasLiveEntry(key);
   }

As per coding guidelines, "Ensure backwards compatibility — core modules are consumed by all agents."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.aiox-core/core/config/config-cache.js around lines 74 - 85, The
TTL/eviction logic is duplicated across has(), get(), and clearExpired() (and
mirrored in the script version); extract that logic into a single helper method
(e.g., isExpiredAndEvict(key) or ensureNotExpired(key)) that checks
timestamps.get(key) vs Date.now(), deletes from this.cache and this.timestamps
when expired, and returns a boolean indicating validity; update has(), get(),
and clearExpired() to call this helper so behavior remains identical and keep
method name/signature stable to preserve backwards compatibility.
tests/synapse/hook-entry.test.js (1)

337-343: Please cover the successful createSession() path too.

These assertions only exercise loadSession() === null plus createSession() === null, so the new happy path in resolveHookRuntime() is still untested. Add one case where createSession() returns a real session object and mirror it in the in-process variant below.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/synapse/hook-entry.test.js` around lines 337 - 343, Add a new test case
(or extend the existing one) so that when loadSession() returns null but
createSession() returns a real session object, resolveHookRuntime() follows the
happy path and returns/uses that session; update both the existing in-process
test and its in-process variant to mock createSession() to return a real session
object (e.g., an object with prompt_count and any other expected fields) and add
assertions that the resolved session equals that object and that any derived
values (such as prompt_count) match; target the functions loadSession,
createSession, and resolveHookRuntime in tests/synapse/hook-entry.test.js to
ensure coverage of the successful createSession path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.claude/hooks/precompact-session-digest.cjs:
- Around line 73-75: The hook currently calls resolveRunnerPath() then executes
the runner which uses setImmediate(async => ...) in-process, keeping the event
loop alive; instead spawn a detached child process to run
.aiox-core/hooks/unified/runners/precompact-runner.js so the digest is
fire-and-forget. Modify the code that uses resolveRunnerPath() / runnerPath to
fork or spawn a detached process (child_process.fork or spawn) with the runner
path as the module/entry, set detached: true, stdio: 'ignore', and call
child.unref() so the parent can exit immediately; ensure any args or env needed
by the runner are forwarded and preserve the existing early return when
!runnerPath.

In @.claude/hooks/synapse-engine.cjs:
- Around line 90-94: The timeout callback in run() currently sets
process.exitCode = 0 which doesn't force termination; change the callback to
call process.exit(0) so the 5s limit is enforced (i.e., replace the body of the
setTimeout in run() to invoke process.exit(0)); keep the existing timer handling
(including timer.unref() if present) but ensure the timeout uses process.exit(0)
to forcibly terminate when HOOK_TIMEOUT_MS elapses.

---

Nitpick comments:
In @.aiox-core/core/config/config-cache.js:
- Around line 74-85: The TTL/eviction logic is duplicated across has(), get(),
and clearExpired() (and mirrored in the script version); extract that logic into
a single helper method (e.g., isExpiredAndEvict(key) or ensureNotExpired(key))
that checks timestamps.get(key) vs Date.now(), deletes from this.cache and
this.timestamps when expired, and returns a boolean indicating validity; update
has(), get(), and clearExpired() to call this helper so behavior remains
identical and keep method name/signature stable to preserve backwards
compatibility.

In `@tests/synapse/hook-entry.test.js`:
- Around line 337-343: Add a new test case (or extend the existing one) so that
when loadSession() returns null but createSession() returns a real session
object, resolveHookRuntime() follows the happy path and returns/uses that
session; update both the existing in-process test and its in-process variant to
mock createSession() to return a real session object (e.g., an object with
prompt_count and any other expected fields) and add assertions that the resolved
session equals that object and that any derived values (such as prompt_count)
match; target the functions loadSession, createSession, and resolveHookRuntime
in tests/synapse/hook-entry.test.js to ensure coverage of the successful
createSession path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 184d4eef-e5d1-4164-8b21-7711fa2e342b

📥 Commits

Reviewing files that changed from the base of the PR and between ff711c1 and 3b7c87d.

📒 Files selected for processing (7)
  • .aiox-core/core/config/config-cache.js
  • .aiox-core/core/synapse/runtime/hook-runtime.js
  • .aiox-core/infrastructure/scripts/config-cache.js
  • .aiox-core/install-manifest.yaml
  • .claude/hooks/precompact-session-digest.cjs
  • .claude/hooks/synapse-engine.cjs
  • tests/synapse/hook-entry.test.js

Comment on lines +73 to 75
const runnerPath = resolveRunnerPath();
if (!runnerPath) return; // Runner not available — silent exit

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import subprocess, textwrap, time
script = textwrap.dedent("""
async function onPreCompact() {
  setImmediate(async () => {
    await new Promise((resolve) => setTimeout(resolve, 2000));
  });
}
onPreCompact().then(() => {
  process.exitCode = 0;
});
""")
start = time.time()
subprocess.run(['node', '-e', script], check=True)
print(f'elapsed={time.time() - start:.2f}s')
PY

Repository: SynkraAI/aiox-core

Length of output: 75


Move the digest work to a detached child process or worker.

Now that installed projects can resolve the runner, the hook executes .aiox-core/hooks/unified/runners/precompact-runner.js, which queues the digest via setImmediate(async () => ...) inside the current process. This keeps the event loop alive until the async work completes, causing the compaction to block or wait for the 9-second timeout instead of remaining fire-and-forget.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/hooks/precompact-session-digest.cjs around lines 73 - 75, The hook
currently calls resolveRunnerPath() then executes the runner which uses
setImmediate(async => ...) in-process, keeping the event loop alive; instead
spawn a detached child process to run
.aiox-core/hooks/unified/runners/precompact-runner.js so the digest is
fire-and-forget. Modify the code that uses resolveRunnerPath() / runnerPath to
fork or spawn a detached process (child_process.fork or spawn) with the runner
path as the module/entry, set detached: true, stdio: 'ignore', and call
child.unref() so the parent can exit immediately; ensure any args or env needed
by the runner are forwarded and preserve the existing early return when
!runnerPath.

Comment on lines +90 to +94
/** Entry point runner — lets Node exit naturally after stdout flush. */
function run() {
const timer = setTimeout(() => safeExit(0), HOOK_TIMEOUT_MS);
const timer = setTimeout(() => {
process.exitCode = 0;
}, HOOK_TIMEOUT_MS);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import subprocess, textwrap, time
script = textwrap.dedent("""
setInterval(() => {}, 1000);
setTimeout(() => {
  process.exitCode = 0;
}, 1000);
""")
start = time.time()
proc = subprocess.Popen(['node', '-e', script], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
  proc.wait(timeout=3)
  print(f'exited_in={time.time() - start:.2f}s code={proc.returncode}')
except subprocess.TimeoutExpired:
  proc.kill()
  print('still_running_after_3s')
PY

Repository: SynkraAI/aiox-core

Length of output: 84


🏁 Script executed:

cat -n .claude/hooks/synapse-engine.cjs | sed -n '80,110p'

Repository: SynkraAI/aiox-core

Length of output: 1033


The timeout callback must use process.exit() to enforce the 5s limit.

process.exitCode = 0 only sets the eventual exit status without terminating the process. If main() hangs on stdout backpressure or other active handles, the timeout callback will not stop execution; the process will wait indefinitely for those handles to close. The timer.unref() call prevents the timer itself from blocking Node, but does not help if other handles remain open.

Suggested fix
 /** Entry point runner — lets Node exit naturally after stdout flush. */
 function run() {
   const timer = setTimeout(() => {
-    process.exitCode = 0;
+    process.exit(0);
   }, HOOK_TIMEOUT_MS);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Entry point runner — lets Node exit naturally after stdout flush. */
function run() {
const timer = setTimeout(() => safeExit(0), HOOK_TIMEOUT_MS);
const timer = setTimeout(() => {
process.exitCode = 0;
}, HOOK_TIMEOUT_MS);
/** Entry point runner — lets Node exit naturally after stdout flush. */
function run() {
const timer = setTimeout(() => {
process.exit(0);
}, HOOK_TIMEOUT_MS);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/hooks/synapse-engine.cjs around lines 90 - 94, The timeout callback
in run() currently sets process.exitCode = 0 which doesn't force termination;
change the callback to call process.exit(0) so the 5s limit is enforced (i.e.,
replace the body of the setTimeout in run() to invoke process.exit(0)); keep the
existing timer handling (including timer.unref() if present) but ensure the
timeout uses process.exit(0) to forcibly terminate when HOOK_TIMEOUT_MS elapses.

@nikolasdehor
Copy link
Contributor

Heads up @Pedrovaleriolopez — esse fix do ConfigCache.has() já está no nosso PR #498 (aberto 24/fev, há mais de 2 semanas). Mesmo bug, mesma solução. O #498 já tá rebased e pronto pra review.

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

Labels

area: agents Agent system related area: cli CLI tools (bin/, packages/aios-pro-cli/) area: core Core framework (.aios-core/core/) area: devops CI/CD, GitHub Actions (.github/) area: docs Documentation (docs/) area: health-check Health check system area: installer Installer and setup (packages/installer/) area: pro Pro features (pro/) area: synapse SYNAPSE context engine area: workflows Workflow system related mcp squad type: test Test coverage and quality

Projects

None yet

2 participants