Skip to content

feat(agent-hooks): install managed Claude hooks into discovered CLAUDE_CONFIG_DIR config dirs#9416

Open
bbingz wants to merge 8 commits into
stablyai:mainfrom
bbingz:bbingz/claude-config-dir-hooks
Open

feat(agent-hooks): install managed Claude hooks into discovered CLAUDE_CONFIG_DIR config dirs#9416
bbingz wants to merge 8 commits into
stablyai:mainfrom
bbingz:bbingz/claude-config-dir-hooks

Conversation

@bbingz

@bbingz bbingz commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

agent-hooks: install managed Claude hooks into discovered CLAUDE_CONFIG_DIR config dirs

Part of #9414. Independent of the
close-intent PR — the two can land in either order.

Problem

Orca's managed Claude status hooks are only written to ~/.claude/settings.json
(and ~/.openclaude). A genuine claude launched with
CLAUDE_CONFIG_DIR=$HOME/.claude-<name> — a common wrapper pattern for
alternate Anthropic-compatible endpoints — loads settings exclusively from that
dir, so it never picks up Orca's hooks and posts no status events: no sidebar
state, no working/done transitions, no tool readout. This affects local, SSH,
and WSL hosts equally.

What this PR does

Discovers Claude-shaped config dirs in the home directory and installs the
SAME managed hooks into each of them, on all three host kinds. No new hook
endpoints: these agents are genuine claude and post to the existing
/hook/claude, via the one shared ~/.orca/agent-hooks/claude-hook.sh(.cmd)
script — N dirs mean N settings.json writes pointing at the same script.

Discovery rules and privacy posture

  • A home-dir entry qualifies when its NAME matches the structural convention
    ^\.claude[-.]<suffix> (no vendor names anywhere in code — dir basenames
    are user-observed data), excluding .claude and .openclaude themselves
    (already managed), AND a marker file exists inside it:
    settings.json | .claude.json | .credentials.json.
  • Validation is readdir + stat ONLY. These dirs hold credentials, so
    file contents are never read, and dir listings / entry names are never
    logged (tests assert both). The marker probe doubles as the directory
    check: a regular file named .claude-foo has no children, so its marker
    stat fails and it is excluded.
  • Why no rc parsing or env reads: shell rc files and wrapper scripts would
    have to be parsed as code to find CLAUDE_CONFIG_DIR assignments, and env
    inspection of running processes leaks exactly the secrets-adjacent context
    we refuse to touch. The dot-dir convention is observable with two cheap,
    content-free syscalls and covers the wrapper pattern in the wild.
  • Discovery fails open: an unlisting home dir or erroring probe yields "no
    extra dirs" and never blocks the default .claude install.

Install ledger

userData/agent-hooks/claude-config-dirs.json records every dir Orca
installed into:

  • Written BEFORE installing (union of previous ledger + new discovery), so a
    crash mid-install still leaves every touched dir tracked.
  • Entries whose dir no longer exists at all are pruned on install and
    skipped by status (nothing is left to clean in a deleted dir), so a
    legitimately deleted flavor dir cannot pin the claude row at partial;
    entries whose dir exists but whose removal failed stay ledgered for retry.
  • Uninstall (the agentStatusHooksEnabled toggle) removes managed entries
    from exactly the ledgered dirs — even dirs discovery no longer returns
    (e.g. marker deleted after install) — then clears the ledger; dirs whose
    removal failed stay ledgered for retry.
  • Ledger entries are re-validated against the naming convention on read, so
    a corrupted/tampered ledger can never drive writes into .claude itself
    or an arbitrary path.
  • Status folds per-dir health into the existing claude row as a count-only
    partial detail; dir names never appear in status text or logs.

Concurrency safety (from adversarial review)

The settings.json read-modify-write had no stale check: a write by the CLI
itself (or a second Orca instance) landing between Orca's read and its
atomic replace was silently clobbered. Now:

  • updateHooksJsonWithRetry re-reads the file just before the tmp→rename
    replace; on divergence the attempt is discarded and re-run against fresh
    content (bounded attempts; the final attempt drops the guard so install
    still converges under a pathological writer). Claude install/remove — and
    therefore every discovered-dir service — use it. A mutate callback may
    return null to signal "nothing to change", which skips the write
    entirely — remove() uses this so a no-op removal never creates a missing
    settings.json, reformats a user file Orca never managed, or rolls its
    .bak.
  • Residual lost-update window (deliberate): the final retry attempt writes
    unguarded, so a writer that races us on every attempt can still have keys
    written inside the last read→rename window clobbered (only keys present at
    our final read survive). The guard's CAS re-read also happens just before
    the .bak copy + rename, leaving a small TOCTOU window even on guarded
    attempts. Both are bounded and accepted: install re-runs on next app
    start, and converging beats failing here.
  • Remote (SFTP) settings writes additionally take a one-shot
    settings.json.orca-backup before Orca's FIRST modification of an
    existing file. Unlike the local rolling .bak it is never rotated, so the
    pre-Orca original stays recoverable across a transport with more
    partial-failure modes.

Remote parity (SSH + WSL)

Discovery runs inside installRemoteManagedAgentHooks over the same
SFTP-shaped transport, after the static agents — so SSH (real SFTPWrapper,
after ssh-relay-session resolves remote $HOME) and WSL (the fs-bridge
adapter, which has readdir/stat) inherit it with zero per-host wiring.
POSIX remotes only, matching the existing scope. The
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS parity invariant intentionally keeps
covering static agents only: discovered dirs are dynamic per-host instances
of the already-registered claude agent, exercised by their own remote and
WSL tests. The remote flow is install-only today, so no remote ledger exists
to consume (documented in code).

Flavor label

The POSIX hook script and the Windows .cmd variant post a configDir form
field — the CLAUDE_CONFIG_DIR path string only, never tokens; empty for
default installs (cmd batch context expands an undefined %VAR% to empty).
normalizeHookPayload threads it into the parsed status payload for claude
posts; it persists/hydrates like any other payload field. The compact
sidebar agent row renders Claude · <flavor> (~/.claude-grok → "grok",
.claude.foo → "foo") when an entry carries a non-default configDir.
Payloads without the field behave exactly as today, so already-installed
scripts keep working unchanged (protocol stays v1 — the field is additive).

CLI compatibility (standalone orca binary)

The ledger module rides in the CLI tsc build (orca agent-hooks enable/disable/status run the real installers offline), and the packaged
CLI is plain Node under ELECTRON_RUN_AS_NODE with no electron module in
node_modules. The module is therefore electron-free: it lazy-requires
electron for app.getPath('userData') and falls back to the CLI's existing
default-userData convention, now extracted to
src/shared/orca-user-data-path.ts and shared with
src/cli/runtime/metadata.ts — so the app and the offline CLI resolve the
same ledger file. A guard test (src/cli/cli-build-electron-free.test.ts)
scans the whole tsconfig.cli.json include set and fails on any static
electron import.

Diagnosability

A per-dir install that ends in state: 'error' emits one name-free
console.warn (local and remote flows alike) — dir names are user-private
observed data and never appear in logs; detailed health remains visible via
the aggregated hook status.

Incidental refactor

The concurrency guard pushed two files over the 300-line lint cap (no
max-lines bumps allowed), so two mechanical extractions ride along:
hooks-json-file.ts (hooks settings JSON types + read/write/retry,
re-exported from installer-utils.ts so call sites keep their import
surface) and sftp-file-primitives.ts (the timeout-guarded ssh2 wrappers).
No behavior change.

Known limitations

  • Config dirs outside the $HOME dot-dir convention are missed: an absolute
    CLAUDE_CONFIG_DIR=/opt/agents/claude-x, or a dir whose name doesn't
    start with .claude-/.claude., is invisible to stat-only discovery.
    Deliberate trade-off — see the privacy posture above.
  • Windows SSH remotes are unchanged: the remote installer flow already skips
    them (POSIX script bodies only), and discovered dirs follow that scope.
  • One-time hook-trust prompt: Claude Code may prompt once per NEWLY hooked
    config dir to trust the hook command, exactly as it did for the first
    .claude install. Expected, one-time, per dir.
  • Discovered-dir installs on remotes are install-only (no remote uninstall
    flow exists today for any agent).
  • A dir created after startup is picked up on the next install pass (app
    start or toggling agentStatusHooksEnabled), not live.

Tests

  • claude-config-dir-discovery.test.ts — fixture home with .claude,
    .claude-grok (marker), .claude.vertex (dot separator), .claudeX (no
    separator), .claude-empty (no marker), a regular file .claude-file,
    unrelated dot-dirs; asserts stat/readdir-only via the injected fs, probe
    paths restricted to marker files of matching candidates, nothing logged,
    fail-open on listing/probe errors; SFTP-shaped discovery incl. failure
    path; label derivation (~/.claude-grok → "grok", .claude.foo → "foo",
    Windows paths, null cases).
  • claude-config-dir-hook-controls.test.ts — multi-dir install writes
    managed entries into every discovered dir (same shared script); ledger
    union survives discovery-set changes; uninstall cleans exactly the
    ledgered dirs and leaves untracked dirs untouched; failed removals stay
    ledgered; fully deleted dirs are pruned from install/status and never
    recreated by remove; corrupt/tampered ledger filtered; count-only status
    aggregation; name-free warn on per-dir install failure; no logging on the
    success path.
  • hook-service.test.tsremove() no-op contract: does not create a
    missing settings.json/config dir, leaves a user file with no managed
    hooks byte-identical (no reformat, no .bak), and still cleans managed
    entries while preserving user hooks after a real install.
  • cli-build-electron-free.test.ts — no static electron import anywhere in
    the CLI build graph.
  • installer-utils.test.ts — concurrent settings.json mutation between read
    and write takes the retry path with no lost user keys; unparseable file
    aborts without writing; final attempt converges under a pathological
    writer.
  • installer-utils-remote.test.ts — one-shot backup written before first
    modification, never rotated, absent on first install.
  • remote-hook-service-installers.test.ts — aggregate SSH installer writes
    managed hooks into a discovered remote dir, skips marker-less matches;
    parity invariant still green.
  • wsl-hook-relay-manager.test.ts — end-to-end WSL fs-bridge run installs
    into a discovered guest config dir.
  • agent-hook-listener.test.ts / agent-status-types.test.tsconfigDir
    round-trips into the status payload; absent/empty field unchanged
    (back-compat); length-capped; hook script bodies carry the field and never
    a token (asserted for POSIX and the win32 .cmd).

Verification

  • tsc --noEmit on all three projects (node, cli, web): clean.
  • oxlint on every touched file, plus type-aware switch-exhaustiveness,
    max-lines ratchet, reliability gates: clean.
  • Targeted vitest: 15 files, 653 passed, 4 skipped (win32-only, on macOS).
    Post-review-fix re-run of the affected files (11 files): 132 passed,
    4 skipped, 0 failed.
  • End-to-end CLI smoke: compiled the CLI with tsc -p config/tsconfig.cli.json
    and ran node out/cli/index.js agent hooks status / agent hooks off
    against a sandboxed HOME + ORCA_USER_DATA_PATH — commands complete
    (previously the electron import crashed/broke them), and toggling off in a
    pristine home no longer creates ~/.claude.

bbingz added 7 commits July 19, 2026 10:31
…l convention

Home-dir entries matching .claude-<name> / .claude.<name> are treated as
Claude config dirs when a marker file (settings.json | .claude.json |
.credentials.json) exists inside. stat/readdir only — contents are never
read and listings are never logged (these dirs hold credentials). Works
over local fs and any SFTP-shaped transport (SSH, WSL fs bridge). The
shared flavor-label helper turns a configDir path into the sidebar label
suffix (~/.claude-grok -> grok).
- updateHooksJsonWithRetry: read-modify-write with a compare-and-retry
  stale check (re-read before replace, bounded attempts, guard dropped on
  the final attempt so install converges). ClaudeHookService install/remove
  now re-merge instead of clobbering keys a concurrent writer (the CLI
  itself, a second Orca instance) added between read and replace.
- Remote (SFTP) writes take a one-shot .orca-backup of an existing
  settings file before Orca's first modification; never rotated later.
- Widen ClaudeCompatibleHookSettings.configDirName to string so discovered
  CLAUDE_CONFIG_DIR flavor dirs can reuse the per-dir service shape.
…g dirs (local)

One ClaudeHookService per discovered ~/.claude-<name> dir — N dirs mean N
settings.json writes all pointing at the one shared claude-hook script.
The lifecycle rides the existing claude entry in the managed
install/remove/status flow (same agentStatusHooksEnabled gate).

Install ledger under userData/agent-hooks records every dir Orca wrote
into (union across runs, written before installing), so uninstall cleans
exactly the touched dirs even when discovery results change later.
Ledger entries are re-validated against the naming convention on read.
Status folds per-dir health into the claude row as a count-only detail —
dir names never appear in status text or logs.
…and WSL remotes

installRemoteManagedAgentHooks now runs config-dir discovery over the same
SFTP-shaped transport after the static agents, so SSH remotes and the WSL
fs bridge both inherit it with no per-host wiring. POSIX remotes only, as
before (Windows remotes stay skipped upstream). Discovery/install failures
degrade status reporting only and log no dir names. The static-agent
parity invariant is unchanged by design — dynamic dirs are instances of
the registered claude agent, covered by their own remote + WSL tests.
…into modules

Mechanical extraction, no behavior change: the hooks settings JSON contract
(types + read/write/retry primitives) moves to hooks-json-file.ts and the
timeout-guarded ssh2 wrappers move to sftp-file-primitives.ts. Restores the
max-lines budget the concurrency guard exceeded; installer-utils re-exports
the JSON surface so call sites keep their single historical import.
…sidebar

The POSIX hook script and the Windows .cmd variant post a configDir form
field (path string only — never tokens; empty for default installs). The
listener threads it into the parsed status payload for claude posts, it
persists/hydrates like other payload fields, and the compact sidebar agent
row renders 'Claude · <flavor>' (~/.claude-grok -> grok) when an entry
carries a non-default config dir. Payloads without the field behave
exactly as before (back-compat with already-installed scripts).
- Make claude-config-dir-hook-controls electron-free: the module rides in
  the standalone orca CLI build (ELECTRON_RUN_AS_NODE, no electron in
  packaged node_modules), so a static electron import crashed every CLI
  command. Lazy-require electron and fall back to the shared userData
  convention, extracted to src/shared/orca-user-data-path.ts and reused by
  src/cli/runtime/metadata.ts. Guard test scans the whole tsconfig.cli.json
  include set for static electron imports.
- Reinstate the remove() no-op gate: updateHooksJsonWithRetry mutate may
  return null (nothing to change) so a no-op removal no longer creates a
  missing settings.json, reformats/.bak-rolls a user file without managed
  hooks, or resurrects a deleted flavor dir.
- Prune ledger entries whose dir was deleted entirely (install persists the
  prune, status skips them) so a legitimately deleted dir cannot pin the
  aggregated claude status at 'partial' forever.
- Emit one name-free console.warn when a discovered-dir install fails,
  matching the remote flow's diagnosability.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds Claude flavor config-directory discovery and ledgered hook lifecycle management for local, remote, and WSL environments. Hook JSON updates gain atomic writes, backups, and stale-write retries, while SFTP operations are centralized behind timeout-guarded primitives. Claude hook payloads now include bounded configDir data, which is propagated through IPC, state, and sidebar identity labels. CLI runtime path resolution and Electron-free build-graph checks are also added.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template and omits explicit Screenshots, AI Review Report, Security Audit, and Notes sections. Rewrite the PR description using the template headings and add the missing Screenshots, Testing checklist, AI Review Report, Security Audit, and Notes sections.
Docstring Coverage ⚠️ Warning Docstring coverage is 26.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the PR's main change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/claude/hook-service.ts (1)

305-330: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

remove() silently swallows updateHooksJsonWithRetry failures.

Unlike install() (lines 222-233), which checks the return value and reports state: 'error' when the retry-update fails, remove() discards the result entirely. If all retry attempts fail (unparseable baseline or repeated write/backup failures under concurrent settings.json edits), the managed hooks are not actually removed, but remove() still proceeds to getStatus() without ever surfacing an error state or detail explaining why removal did not take effect.

🛡️ Proposed fix mirroring install()'s error handling
-    updateHooksJsonWithRetry(configPath, (current) => {
+    const updated = updateHooksJsonWithRetry(configPath, (current) => {
       const { config: next, changed } = removeManagedHooks(
         current,
         getManagedScriptFileName(this.options.settings)
       )
       return changed ? next : null
     })
+    if (!updated) {
+      return {
+        agent: this.options.agent,
+        state: 'error',
+        configPath,
+        managedHooksPresent: false,
+        detail: `Could not parse ${this.options.displayName} settings.json`
+      }
+    }
     return this.getStatus()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9b972146-206b-4003-905f-181777cc5193

📥 Commits

Reviewing files that changed from the base of the PR and between 02ff7c7 and a5826ea.

📒 Files selected for processing (29)
  • config/tsconfig.cli.json
  • src/cli/cli-build-electron-free.test.ts
  • src/cli/runtime/metadata.ts
  • src/main/agent-hooks/hooks-json-file.ts
  • src/main/agent-hooks/installer-utils-remote.test.ts
  • src/main/agent-hooks/installer-utils-remote.ts
  • src/main/agent-hooks/installer-utils.test.ts
  • src/main/agent-hooks/installer-utils.ts
  • src/main/agent-hooks/managed-agent-hook-controls.ts
  • src/main/agent-hooks/remote-hook-service-installers.test.ts
  • src/main/agent-hooks/remote-managed-hook-installers.ts
  • src/main/agent-hooks/sftp-file-primitives.ts
  • src/main/agent-hooks/wsl-hook-relay-manager.test.ts
  • src/main/claude/claude-config-dir-discovery.test.ts
  • src/main/claude/claude-config-dir-discovery.ts
  • src/main/claude/claude-config-dir-hook-controls.test.ts
  • src/main/claude/claude-config-dir-hook-controls.ts
  • src/main/claude/hook-service.test.ts
  • src/main/claude/hook-service.ts
  • src/main/claude/hook-settings.ts
  • src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
  • src/renderer/src/hooks/useIpcEvents.ts
  • src/renderer/src/store/slices/agent-status.ts
  • src/shared/agent-hook-listener.test.ts
  • src/shared/agent-hook-listener.ts
  • src/shared/agent-status-types.test.ts
  • src/shared/agent-status-types.ts
  • src/shared/claude-config-dir-label.ts
  • src/shared/orca-user-data-path.ts

Comment thread src/main/agent-hooks/installer-utils-remote.ts
Comment thread src/main/claude/claude-config-dir-hook-controls.ts Outdated
Comment thread src/main/claude/claude-config-dir-hook-controls.ts Outdated
Comment thread src/main/claude/hook-service.ts
@bbingz

bbingz commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the remaining non-inline finding from CodeRabbit review #9416 (review) in f8dd301. ClaudeHookService.remove() now checks the updateHooksJsonWithRetry result and returns an error status when the guarded update cannot complete; a forced-update-failure regression test covers this path.

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/claude/claude-config-dir-hook-controls.ts (1)

125-144: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove redundant remaining array to prevent potential race conditions.

The remaining array collects directories that encountered an error during removal. However, because these directories are never added to the removed Set, they are inherently preserved by the current.filter((dirName) => !removed.has(dirName)) logic.

Concatenating ...remaining is not only redundant but can introduce a race condition: if a concurrent process successfully removes one of these directories from the ledger before this callback executes, unconditionally appending ...remaining will erroneously resurrect the deleted directory.

🧹 Proposed fix to rely strictly on the `removed` Set
-  const remaining: string[] = []
   const removed = new Set<string>()
   for (const dirName of readClaudeConfigDirLedger(ledgerPath)) {
     let status: AgentHookInstallStatus
     try {
       status = createService(dirName).remove()
     } catch (error) {
       status = errorStatus(dirName, error)
     }
-    if (status.state === 'error') {
-      remaining.push(dirName)
-    } else {
+    if (status.state !== 'error') {
       removed.add(dirName)
     }
     statuses.push(status)
   }
-  updateClaudeConfigDirLedger(ledgerPath, (current) => [
-    ...current.filter((dirName) => !removed.has(dirName)),
-    ...remaining
-  ])
+  updateClaudeConfigDirLedger(ledgerPath, (current) =>
+    current.filter((dirName) => !removed.has(dirName))
+  )

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 033efd53-ee65-4cdc-91c5-266b94085983

📥 Commits

Reviewing files that changed from the base of the PR and between a5826ea and f8dd301.

📒 Files selected for processing (9)
  • config/tsconfig.cli.json
  • src/main/agent-hooks/installer-utils-remote.test.ts
  • src/main/agent-hooks/installer-utils-remote.ts
  • src/main/claude/claude-config-dir-discovery.ts
  • src/main/claude/claude-config-dir-hook-controls.test.ts
  • src/main/claude/claude-config-dir-hook-controls.ts
  • src/main/claude/claude-config-dir-ledger.ts
  • src/main/claude/hook-service.test.ts
  • src/main/claude/hook-service.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • config/tsconfig.cli.json
  • src/main/claude/claude-config-dir-hook-controls.test.ts
  • src/main/agent-hooks/installer-utils-remote.ts
  • src/main/claude/hook-service.ts

Comment on lines +88 to +106
const tmpPath = join(dirname(ledgerPath), `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeFileSync(tmpPath, serialized, 'utf-8')
// Why: a second Orca process may have updated the shared ledger after our
// read. Abort and re-merge instead of replacing its newly tracked dirs.
if (readLedgerBaseline(ledgerPath) !== expectedDiskContent) {
return false
}
renameSync(tmpPath, ledgerPath)
return true
} finally {
if (existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// Best-effort cleanup after a failed or stale replace.
}
}
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fix the time-of-check to time-of-use (TOCTOU) race condition during file update.

Although this logic attempts an optimistic compare-and-swap, the sequence of readLedgerBaseline followed by renameSync is not atomic. If two processes concurrently verify the baseline and both proceed to renameSync, the earlier process's update will be silently overwritten and lost.

To achieve mutual exclusion and make the compare-and-rename operation truly atomic across processes, you can use a lock file with the exclusive creation flag (wx). This flawlessly integrates with your existing maxAttempts retry loop.

🔒️ Proposed fix to synchronize updates
-  const tmpPath = join(dirname(ledgerPath), `.${Date.now()}-${randomUUID()}.tmp`)
-  try {
-    writeFileSync(tmpPath, serialized, 'utf-8')
-    // Why: a second Orca process may have updated the shared ledger after our
-    // read. Abort and re-merge instead of replacing its newly tracked dirs.
-    if (readLedgerBaseline(ledgerPath) !== expectedDiskContent) {
-      return false
-    }
-    renameSync(tmpPath, ledgerPath)
-    return true
-  } finally {
-    if (existsSync(tmpPath)) {
-      try {
-        unlinkSync(tmpPath)
-      } catch {
-        // Best-effort cleanup after a failed or stale replace.
-      }
-    }
-  }
+  const lockPath = `${ledgerPath}.lock`
+  try {
+    // Atomically acquire lock
+    writeFileSync(lockPath, '', { flag: 'wx' })
+  } catch (err) {
+    if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
+      return false // Another process is currently updating; trigger a retry
+    }
+    throw err
+  }
+
+  const tmpPath = join(dirname(ledgerPath), `.${Date.now()}-${randomUUID()}.tmp`)
+  try {
+    writeFileSync(tmpPath, serialized, 'utf-8')
+    // Why: a second Orca process may have updated the shared ledger after our
+    // read. Abort and re-merge instead of replacing its newly tracked dirs.
+    if (readLedgerBaseline(ledgerPath) !== expectedDiskContent) {
+      return false
+    }
+    renameSync(tmpPath, ledgerPath)
+    return true
+  } finally {
+    try {
+      unlinkSync(lockPath)
+    } catch {
+      // Best-effort cleanup
+    }
+    if (existsSync(tmpPath)) {
+      try {
+        unlinkSync(tmpPath)
+      } catch {
+        // Best-effort cleanup after a failed or stale replace.
+      }
+    }
+  }
📝 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
const tmpPath = join(dirname(ledgerPath), `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeFileSync(tmpPath, serialized, 'utf-8')
// Why: a second Orca process may have updated the shared ledger after our
// read. Abort and re-merge instead of replacing its newly tracked dirs.
if (readLedgerBaseline(ledgerPath) !== expectedDiskContent) {
return false
}
renameSync(tmpPath, ledgerPath)
return true
} finally {
if (existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// Best-effort cleanup after a failed or stale replace.
}
}
}
const lockPath = `${ledgerPath}.lock`
try {
// Atomically acquire lock
writeFileSync(lockPath, '', { flag: 'wx' })
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
return false // Another process is currently updating; trigger a retry
}
throw err
}
const tmpPath = join(dirname(ledgerPath), `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeFileSync(tmpPath, serialized, 'utf-8')
// Why: a second Orca process may have updated the shared ledger after our
// read. Abort and re-merge instead of replacing its newly tracked dirs.
if (readLedgerBaseline(ledgerPath) !== expectedDiskContent) {
return false
}
renameSync(tmpPath, ledgerPath)
return true
} finally {
try {
unlinkSync(lockPath)
} catch {
// Best-effort cleanup
}
if (existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// Best-effort cleanup after a failed or stale replace.
}
}
}

@brennanb2025

Copy link
Copy Markdown
Contributor

Taking a look!

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.

3 participants