From 6eda9cdb27ce7354f2261f860a8a5e9faf9255ff Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:32:31 -0700 Subject: [PATCH 1/6] fix(ssh): bound healthy relay watcher processes (#8556) --- config/reliability-gates.jsonc | 16 +-- .../relay-filesystem-watch-registry.test.ts | 101 +++++++++++++++++- src/relay/relay-watcher-process-pool.ts | 3 + 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index fc02e5fb135..6a9ba8db139 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3924,14 +3924,14 @@ "remote-runtime", "ssh" ], - "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool plus at most four bounded quarantine children, one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH now bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.", + "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool, a one-child healthy SSH relay pool, and at most four bounded quarantine children, with one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/5308", "https://github.com/stablyai/orca/issues/7547", "https://github.com/stablyai/orca/issues/8212" ], - "invariant": "Local runtime and SSH relay roots use at most four shared watcher children while healthy and at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and the SSH relay, PTYs, Git, and other filesystem operations never load the watcher native addon or share its fault boundary.", - "oracle": "Fill the four-child healthy pool with multiple roots, inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", + "invariant": "Local runtime roots use at most four shared watcher children while healthy, while each SSH relay process uses at most one; either pool uses at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and each SSH target's relay, PTYs, Git, and other filesystem operations remain isolated from other targets and never load the watcher native addon or share its fault boundary.", + "oracle": "Fill the four-child healthy runtime pool with multiple roots, and map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor. Inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts", "pnpm run build:electron-vite", @@ -4043,6 +4043,8 @@ { "file": "src/relay/relay-filesystem-watch-registry.test.ts", "assertions": [ + "each SSH host keeps its own healthy watcher supervisor while a standard repository's three distinct roots share that one child", + "shared-child failure moves those roots into separate quarantine supervisors with overflow and resumed events", "replacement emits overflow before later ordered events", "terminal shard failure preserves same-root clients through recovery", "pending setup aborts only after the last same-root client leaves", @@ -4155,11 +4157,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Red tests reproduced the reviewed failures: IPC disconnect-before-exit orphaned existing roots, queue-admission deadlines cascaded shard replacement, a fused shard created one child per root, destructive desktop cleanup waited indefinitely for native setup, and synchronous renderer retry joined a terminal stream. Earlier red coverage also reproduced missing-entry fail-open, terminal recovery loss, stale same-root lease deletion, and unbounded per-child stat lanes. The bounded quarantine and cancellation rewrite passes those contracts, and the real-supervisor built-entry harness survives SIGSEGV with automatic replacement. CI/soak history is still absent." + "evidence": "Red tests reproduced the reviewed failures: IPC disconnect-before-exit orphaned existing roots, queue-admission deadlines cascaded shard replacement, a fused shard created one child per root, a standard SSH repository created three healthy watcher supervisors, destructive desktop cleanup waited indefinitely for native setup, and synchronous renderer retry joined a terminal stream. Earlier red coverage also reproduced missing-entry fail-open, terminal recovery loss, stale same-root lease deletion, and unbounded per-child stat lanes. The bounded quarantine and cancellation rewrite passes those contracts, the relay-specific healthy width test reduces the three-root case to one supervisor, and the real-supervisor built-entry harness survives SIGSEGV with automatic replacement. CI/soak history is still absent." }, "performanceBudget": { "required": true, - "evidence": "Healthy operation is capped at four shared child processes instead of one child per root; based on the prior 51.9 MiB one-child measurement this bounds the unremeasured eight-root healthy ceiling near 207.6 MiB rather than the rejected 388.7 MiB per-root design. Degraded operation adds at most four quarantine children, distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most 32 healthy or 64 total logical stat operations), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added." + "evidence": "Local runtime operation remains capped at four shared child processes, while each SSH relay is capped at one healthy child instead of spreading a standard repository's three roots across three children. Based on the measured 51.9 MiB per child, normal SSH watcher overhead is bounded near 51.9 MiB rather than 155.7 MiB before relay and PTY overhead. Degraded operation adds at most four quarantine children, distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most 32 healthy or 64 total logical stat operations for local runtime; 8 healthy or 40 total per SSH relay), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added." }, "promotionCriteria": [ "Run the built-entry SIGSEGV harness in soak for at least 100 consecutive passes or 14 days on macOS.", @@ -4168,11 +4170,11 @@ ], "knownGaps": [ "The fault harness bundles the real source supervisor and drives the built child, but it does not launch a packaged orca serve pairing session or exercise app.asar path resolution.", - "The four-child healthy and four-child quarantine memory ceilings are inferred from the earlier one-child measurement; direct degraded-state RSS plus sustained six-writer event-loop-delay and CPU soak evidence is still absent.", + "The one-child SSH healthy and four-child quarantine memory ceilings are inferred from the earlier one-child measurement; direct degraded-state RSS plus sustained six-writer event-loop-delay and CPU soak evidence is still absent.", "Linux native watcher fault containment is source-equivalent but not live fault-injected in this evidence run.", "Windows runtime watches bypass this path; SSH registration ownership is deterministic-contract tested but not live-relay fault-injected." ], - "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four runtime watcher children, if total healthy plus quarantine operation exceeds eight, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process." + "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four local runtime watcher children or one child in any SSH relay, if total healthy plus quarantine operation exceeds eight locally or five in an SSH relay, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process." } ] } diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 5f1e46eecdd..7c505ae652d 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { tmpdir } from 'node:os' import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' +import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor' import type { WatcherProcessCallback, WatcherProcessHooks, @@ -17,6 +18,29 @@ type InstalledWatch = { unsubscribe: ReturnType Promise>> } +type InstalledSupervisorWatch = InstalledWatch & { dir: string } + +function stubRelayWatcherSupervisors() { + const installed = new Map() + vi.spyOn(WatcherProcessSupervisor.prototype, 'subscribe').mockImplementation(function ( + this: WatcherProcessSupervisor, + dir, + callback, + _options, + hooks = {} + ) { + const watches = installed.get(this) ?? [] + const unsubscribe = vi.fn(async () => undefined) + watches.push({ dir, callback, hooks, unsubscribe }) + installed.set(this, watches) + return Promise.resolve({ unsubscribe }) + }) + const dispose = vi + .spyOn(WatcherProcessSupervisor.prototype, 'dispose') + .mockImplementation(() => undefined) + return { installed, dispose } +} + class FakeWatcherPool { readonly installed: InstalledWatch[] = [] readonly dispose = vi.fn() @@ -148,6 +172,81 @@ describe('RelayFilesystemWatchRegistry', () => { }) describe('createRelayWatcherProcessPool', () => { + afterEach(() => vi.restoreAllMocks()) + + it('uses one healthy watcher process per SSH host for each standard repo', async () => { + const { installed } = stubRelayWatcherSupervisors() + const firstHostPool = createRelayWatcherProcessPool() + const secondHostPool = createRelayWatcherProcessPool() + const repoBase = join(tmpdir(), 'ssh-repo') + const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')] + try { + for (const root of roots) { + await firstHostPool.subscribe(root, vi.fn(), {}, {}) + await secondHostPool.subscribe(root, vi.fn(), {}, {}) + } + + expect( + Array.from(installed.values()).map((watches) => watches.map(({ dir }) => dir)) + ).toEqual([roots, roots]) + } finally { + firstHostPool.dispose() + secondHostPool.dispose() + } + }) + + it('quarantines and recovers each standard-repo root after the shared child fails', async () => { + const { installed, dispose } = stubRelayWatcherSupervisors() + const pool = createRelayWatcherProcessPool() + const dispatcher = createDispatcher() + const registry = new RelayFilesystemWatchRegistry( + dispatcher as unknown as RelayDispatcher, + pool + ) + const repoBase = join(tmpdir(), 'ssh-repo-recovery') + const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')] + try { + for (const root of roots) { + await registry.watch(root, context(1)) + } + const healthyWatches = Array.from(installed.values())[0] + const failure = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + + for (const watch of healthyWatches) { + watch.hooks.onTerminalError?.(failure) + } + await Promise.resolve() + + const recoveredWatches = Array.from(installed.values()).slice(1) + expect(dispose).toHaveBeenCalledTimes(1) + expect(recoveredWatches.map((watches) => watches.map(({ dir }) => dir))).toEqual( + roots.map((root) => [root]) + ) + expect(dispatcher.notify.mock.calls.slice(0, roots.length)).toEqual( + roots.map((root) => ['fs.changed', { events: [{ kind: 'overflow', absolutePath: root }] }]) + ) + + for (const watches of recoveredWatches) { + const [{ callback, dir }] = watches + callback(null, [{ type: 'update', path: join(dir, 'recovered.txt') }]) + } + expect(dispatcher.notify.mock.calls.slice(roots.length)).toEqual( + roots.map((root) => [ + 'fs.changed', + { + events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }] + } + ]) + ) + } finally { + registry.dispose() + } + }) + it('fails closed instead of loading the native watcher in the relay process', async () => { const previousVitest = process.env.VITEST process.env.VITEST = 'true' diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts index af6cb8285e2..e0093b9a166 100644 --- a/src/relay/relay-watcher-process-pool.ts +++ b/src/relay/relay-watcher-process-pool.ts @@ -15,6 +15,9 @@ export function createRelayWatcherProcessPool( entryPath = getRelayWatcherProcessEntryPath() ): RelayWatcherProcessPool { return new RuntimeWatcherProcessPool({ + // Why: one SSH workspace normally installs three roots, and each remote + // watcher child carries substantial Node/native-addon process overhead. + maxSharedSupervisors: 1, createSupervisor: () => new WatcherProcessSupervisor({ entryPath, From 17f032dbd554a0ac237dbaa67c48dbabff06d8b2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:39:52 -0700 Subject: [PATCH 2/6] Fix WSL orchestration CLI preambles (#8561) --- src/main/ipc/pty.test.ts | 40 ++++++++++--- src/main/ipc/pty.ts | 16 ++++- src/main/pty/wsl-orca-env.test.ts | 2 + src/main/pty/wsl-orca-env.ts | 1 + src/main/runtime/orca-runtime.ts | 43 +++++++++++++- .../runtime/orchestration/cli-command.test.ts | 59 +++++++++++++++++++ src/main/runtime/orchestration/cli-command.ts | 25 ++++++++ .../runtime/orchestration/coordinator.test.ts | 7 +++ src/main/runtime/orchestration/coordinator.ts | 6 ++ .../runtime/orchestration/preamble.test.ts | 11 +++- src/main/runtime/orchestration/preamble.ts | 7 ++- .../runtime/rpc/methods/orchestration.test.ts | 16 +++++ src/main/runtime/rpc/methods/orchestration.ts | 14 +++-- 13 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 src/main/runtime/orchestration/cli-command.test.ts create mode 100644 src/main/runtime/orchestration/cli-command.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 35d386eb473..4838711803d 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1702,6 +1702,7 @@ describe('registerPtyHandlers', () => { // (the GNOME screen reader) inside Orca-managed terminals (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) + expect(env.ORCA_CLI_COMMAND).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -1801,10 +1802,13 @@ describe('registerPtyHandlers', () => { // Why: runtime-created spawns (e.g. the mobile-create materialize path) // must thread the same {tabId, leafId} so the catch-path rescue can find // and keep their live PTY (#7587). - expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-runtime', null, { - tabId: 'tab-1', - leafId - }) + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'wt-runtime', + null, + { tabId: 'tab-1', leafId }, + false + ) }) it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => { @@ -1859,6 +1863,13 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.shellOverride).toBe('wsl.exe') expect(spawnOptions.terminalWindowsWslDistro).toBe('Ubuntu') expect(spawnOptions.terminalWindowsPowerShellImplementation).toBe('auto') + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'repo-1::C:\\repo', + null, + undefined, + true + ) }) }) @@ -4558,10 +4569,13 @@ describe('registerPtyHandlers', () => { // Why: this is the load-bearing wiring for #7587 — the runtime can only back a // stalled mobile create from a live spawn if the spawn threads {tabId, leafId}. - expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, { - tabId: 'tab-1', - leafId - }) + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'wt-1', + null, + { tabId: 'tab-1', leafId }, + false + ) }) it('omits the pane identity from registerPty when the leafId is not a terminal leaf (#7587)', async () => { @@ -4590,7 +4604,13 @@ describe('registerPtyHandlers', () => { // Why: legacy numeric pane ids (`pane:N`) are not terminal leaf ids, so the // spawn seam must not fabricate a binding for them (registerPty would ignore // it anyway); this pins that the seam passes a clean `undefined`. - expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, undefined) + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'wt-1', + null, + undefined, + false + ) }) it('refreshes native Agent Teams env when captured teammate mode lives in launch args', async () => { @@ -6069,10 +6089,12 @@ describe('registerPtyHandlers', () => { expect(spawnCall[0]).toBe('wsl.exe') expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + expect(env.ORCA_CLI_COMMAND).toBe('orca-ide') expect(env.WSLENV?.split(':')).toEqual( expect.arrayContaining([ 'ORCA_TERMINAL_HANDLE/u', 'ORCA_USER_DATA_PATH/p', + 'ORCA_CLI_COMMAND/u', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 305ebabfc14..0dd30b24b1e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -980,8 +980,14 @@ export function buildPtyHostEnv( // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - } else if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + // Why: managed WSL registration deliberately uses `orca-ide`; exposing + // that literal keeps agent guidance scoped to WSL without a bare-orca shim. + baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' + } else { + if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + delete baseEnv.ORCA_CLI_COMMAND } // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. if (!opts.isPackaged) { @@ -3234,6 +3240,9 @@ export function registerPtyHandlers( args.tabId.length <= 512 && metadataLeafId !== null ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) : undefined ) } @@ -4282,6 +4291,9 @@ export function registerPtyHandlers( args.tabId.length <= 512 && metadataLeafId !== null ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) : undefined ) } diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 550295460ad..fe0d94ab9cb 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -24,6 +24,7 @@ describe('addOrcaWslInteropEnv', () => { const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl', ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', + ORCA_CLI_COMMAND: 'orca-ide', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', @@ -38,6 +39,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') + expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 161621e8c0f..3f136e0a284 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -24,6 +24,7 @@ export function addOrcaWslInteropEnv(env: Record): void { const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', 'ORCA_USER_DATA_PATH/p', + 'ORCA_CLI_COMMAND/u', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 293075176e9..09185299c82 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -469,7 +469,9 @@ import { resolveLocalProjectRuntimeForRepo, resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options' +import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -976,6 +978,9 @@ type RuntimePtyWorktreeRecord = { ptyId: string worktreeId: string connectionId: string | null + // Why: a Windows host can own both native and WSL panes; preamble command + // selection must follow the pane that executes it, not process.platform. + isWsl: boolean | null // Why: background CLI PTYs can outlive a failed renderer reveal. Preserve the // spawn-time tab/pane identity so later reveals can adopt under the env key. tabId: string | null @@ -5529,7 +5534,8 @@ export class OrcaRuntimeService { ptyId: string, worktreeId: string, connectionId: string | null = null, - binding?: { tabId: string; leafId: string } + binding?: { tabId: string; leafId: string }, + isWsl?: boolean ): void { // Why: record the renderer pane identity at spawn time so a stalled graph // sync can't hide that a live PTY already backs a pending mobile create. @@ -5540,6 +5546,7 @@ export class OrcaRuntimeService { this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId, + ...(isWsl !== undefined ? { isWsl } : {}), ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}) }) // Why: the renderer's own PTY spawn is the reliable signal that the pending @@ -7234,6 +7241,27 @@ export class OrcaRuntimeService { return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null } + getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' { + let pty: RuntimePtyWorktreeRecord | null = null + try { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + pty = ptyId ? (this.ptysById.get(ptyId) ?? null) : null + } catch { + return 'orca' + } + if (!pty) { + return 'orca' + } + return resolveTerminalOrchestrationCliCommand({ + connectionId: pty.connectionId, + isWsl: pty.isWsl, + worktreeId: pty.worktreeId, + projectRuntime: this.store + ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) + : undefined + }) + } + hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { const ptyId = this.resolveLeafForHandle(handle)?.ptyId const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId) : null @@ -20057,7 +20085,14 @@ export class OrcaRuntimeService { state: Partial< Pick< RuntimePtyWorktreeRecord, - 'connected' | 'lastOutputAt' | 'preview' | 'tabId' | 'paneKey' | 'title' | 'connectionId' + | 'connected' + | 'lastOutputAt' + | 'preview' + | 'tabId' + | 'paneKey' + | 'title' + | 'connectionId' + | 'isWsl' > > = {} ): RuntimePtyWorktreeRecord { @@ -20068,6 +20103,7 @@ export class OrcaRuntimeService { ptyId, worktreeId, connectionId: state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null, + isWsl: state.isWsl ?? null, tabId: state.tabId ?? null, paneKey: state.paneKey ?? null, launchConfig: null, @@ -20109,6 +20145,9 @@ export class OrcaRuntimeService { if (state.connectionId !== undefined) { pty.connectionId = state.connectionId } + if (state.isWsl !== undefined) { + pty.isWsl = state.isWsl + } if (state.tabId !== undefined) { pty.tabId = state.tabId } diff --git a/src/main/runtime/orchestration/cli-command.test.ts b/src/main/runtime/orchestration/cli-command.test.ts new file mode 100644 index 00000000000..1d07d335c8a --- /dev/null +++ b/src/main/runtime/orchestration/cli-command.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalOrchestrationCliCommand } from './cli-command' + +describe('resolveTerminalOrchestrationCliCommand', () => { + it('uses orca-ide for a pane recorded as WSL', () => { + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: true, + worktreeId: 'repo::C:\\repo' + }) + ).toBe('orca-ide') + }) + + it('uses project runtime and WSL paths when restored pane metadata is unavailable', () => { + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: null, + worktreeId: 'repo::C:\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'project:wsl:Ubuntu' + } + } + }) + ).toBe('orca-ide') + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: null, + worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }) + ).toBe('orca-ide') + }) + + it('preserves native and SSH bare-orca commands', () => { + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: false, + worktreeId: 'repo::/home/alice/repo' + }) + ).toBe('orca') + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: 'ssh-1', + isWsl: null, + worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }) + ).toBe('orca') + }) +}) diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts new file mode 100644 index 00000000000..c0f4883f1e4 --- /dev/null +++ b/src/main/runtime/orchestration/cli-command.ts @@ -0,0 +1,25 @@ +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { isWslUncPath } from '../../../shared/wsl-paths' +import { splitWorktreeIdForFilesystem } from '../../../shared/worktree-id' + +export type OrchestrationCliCommand = 'orca' | 'orca-ide' + +export function resolveTerminalOrchestrationCliCommand(args: { + connectionId: string | null + isWsl: boolean | null | undefined + worktreeId: string + projectRuntime?: ProjectExecutionRuntimeResolution +}): OrchestrationCliCommand { + if (args.connectionId) { + return 'orca' + } + if (args.isWsl !== null && args.isWsl !== undefined) { + return args.isWsl ? 'orca-ide' : 'orca' + } + if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') { + return 'orca-ide' + } + + const worktreePath = splitWorktreeIdForFilesystem(args.worktreeId)?.worktreePath + return worktreePath && isWslUncPath(worktreePath) ? 'orca-ide' : 'orca' +} diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 8563c3a1e87..cb532b71e36 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -21,6 +21,7 @@ function createMockRuntime(): CoordinatorRuntime & { createdTerminalOptions: { title?: string }[] probeDriftCalls: string[] probeDriftResult: DriftResult + cliCommand: 'orca' | 'orca-ide' setProbeDrift(result: DriftResult): void throwProbeDrift: Error | null } { @@ -36,6 +37,7 @@ function createMockRuntime(): CoordinatorRuntime & { createdTerminalOptions: [] as { title?: string }[], probeDriftCalls: [] as string[], probeDriftResult: null as DriftResult, + cliCommand: 'orca' as 'orca' | 'orca-ide', throwProbeDrift: null as Error | null, setProbeDrift(result: DriftResult): void { mock.probeDriftResult = result @@ -63,6 +65,9 @@ function createMockRuntime(): CoordinatorRuntime & { throw mock.throwProbeDrift } return mock.probeDriftResult + }, + getTerminalOrchestrationCliCommand() { + return mock.cliCommand } } return mock @@ -121,6 +126,7 @@ describe('Coordinator', () => { it('dispatches a ready task to an available terminal', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() + runtime.cliCommand = 'orca-ide' runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] const task = db.createTask({ spec: 'implement feature' }) @@ -147,6 +153,7 @@ describe('Coordinator', () => { expect(result.status).toBe('completed') expect(result.completedTasks).toContain(task.id) expect(runtime.sentMessages.length).toBeGreaterThan(0) + expect(runtime.sentMessages[0].text).toContain('orca-ide orchestration send') }) it('records the assignee pane key when the runtime can resolve one', async () => { diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 73307eddd35..04f0788577c 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -32,6 +32,9 @@ export type CoordinatorRuntime = { // Why: optional so lightweight runtime fakes keep compiling; when present, // dispatch records the remint-stable pane identity of the assignee. getTerminalPaneKey?(handle: string): string | null + // Why: Windows can host native and WSL workers concurrently, so the + // worker pane—not the coordinator process—selects the packaged CLI name. + getTerminalOrchestrationCliCommand?(handle: string): 'orca' | 'orca-ide' } // Why (§3.1): single threshold, no warn/refuse split. Coordinator picked 20 @@ -486,6 +489,9 @@ export class Coordinator { coordinatorHandle: this.opts.coordinatorHandle, workerHandle: targetHandle, devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev'), + ...(this.runtime.getTerminalOrchestrationCliCommand + ? { cliCommand: this.runtime.getTerminalOrchestrationCliCommand(targetHandle) } + : {}), // Why (§3.2): drift section fires only when behind > 0. The preamble // builder gates on this itself; passing the object unconditionally lets // the coordinator stay dumb about the display rule. diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 198437e4cd7..ef133551034 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -159,7 +159,7 @@ describe('buildDispatchPreamble', () => { }) it('uses orca-dev CLI when devMode is true', () => { - const result = buildDispatchPreamble(baseParams({ devMode: true })) + const result = buildDispatchPreamble(baseParams({ devMode: true, cliCommand: 'orca-ide' })) expect(result).toContain('orca-dev orchestration send') expect(result).toContain('orca-dev orchestration check') expect(result).toContain('orca-dev orchestration ask') @@ -175,6 +175,15 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('orca orchestration check') }) + it('uses the exact orca-ide command for packaged WSL workers', () => { + const result = buildDispatchPreamble(baseParams({ cliCommand: 'orca-ide' })) + + expect(result).toContain('orca-ide orchestration send') + expect(result).toContain('orca-ide orchestration check') + expect(result).toContain('orca-ide orchestration ask') + expect(result).not.toMatch(/(^|\s)orca orchestration/m) + }) + it('appends a BASE DRIFT section when baseDrift.behind > 0', () => { const result = buildDispatchPreamble({ taskId: 'task_x', diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 9aaa488318f..a8012c56854 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -1,3 +1,5 @@ +import type { OrchestrationCliCommand } from './cli-command' + export type PreambleParams = { taskId: string // Why: completion and heartbeat payloads attribute activity to a specific @@ -10,6 +12,9 @@ export type PreambleParams = { coordinatorHandle: string workerHandle: string devMode?: boolean + // Why: packaged WSL panes install the scoped launcher as `orca-ide`; + // other execution hosts keep their existing bare `orca` bridge. + cliCommand?: OrchestrationCliCommand // Why: populated by the coordinator's dispatch pre-flight (§3.1) only // when the target worktree is behind its tracking remote. When absent // or when `behind === 0`, the preamble emits no drift section. Callers @@ -42,7 +47,7 @@ export function buildDispatchPreamble(params: PreambleParams): string { // Why: in dev mode, agents must use orca-dev to connect to the dev runtime's // socket. Without this, agents inside the dev Electron app would call the // production CLI and talk to the wrong Orca instance (Section 6.4). - const cli = params.devMode ? 'orca-dev' : 'orca' + const cli = params.devMode ? 'orca-dev' : (params.cliCommand ?? 'orca') const postDoneInstructions = buildPostWorkerDoneInstructions({ cli, workerKind: params.workerKind ?? 'prompt-returning-agent' diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index fc7536c2e4f..95339cb3190 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1369,6 +1369,22 @@ describe('orchestration RPC methods', () => { ) }) + it('uses the target pane CLI command for the returned preamble', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca-ide') + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_wsl', + returnPreamble: true + })) as { preamble: string } + + expect(runtime.getTerminalOrchestrationCliCommand).toHaveBeenCalledWith('term_wsl') + expect(result.preamble).toContain('orca-ide orchestration send') + expect(result.preamble).not.toMatch(/(^|\s)orca orchestration/m) + }) + it('injects preamble through the agent prompt path instead of raw terminal send', async () => { setup() const task = db.createTask({ spec: 'line one\nline two' }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 9caaf1ac57a..bd2721d1b99 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -495,7 +495,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', workerHandle: params.to ?? 'worker', - devMode: params.devMode + devMode: params.devMode, + ...(params.to + ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } + : {}) }) return { dispatch: null, injected: false, dryRun: true, preamble } } @@ -540,7 +543,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', workerHandle: to, - devMode: params.devMode + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(to) }) let injected = false @@ -583,6 +587,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!task) { throw new Error(`Task not found: ${params.task}`) } + const workerHandle = ctx?.assignee_handle ?? 'worker' const preamble = buildDispatchPreamble({ taskId: task.id, // Why: prefer the existing dispatch context's id if we have one @@ -591,8 +596,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: ctx?.id ?? 'ctx_preview', taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', - workerHandle: ctx?.assignee_handle ?? 'worker', - devMode: params.devMode + workerHandle, + devMode: params.devMode, + ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) }) return { dispatch: ctx ?? null, preamble } } From 6091995e75ea5b3bd88137b306dff081ab45233c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:40:02 -0700 Subject: [PATCH 3/6] perf(worktree-watcher): skip head-identity reads on index-only bursts (#8558) An index rewrite cannot move HEAD, yet every quiet local Git-common status burst was calling readGitCommonHeadIdentities, which scans the primary checkout plus every linked worktree sequentially (~1,500 file reads for 500 worktrees) even when only index files changed. Split the conflated git-status signal into two: index churn stays `gitStatusRepoIds`, while logs/HEAD (and other reflog head moves) become a distinct `headIdentityRepoIds` signal. Source Control is still notified for both, but readGitCommonHeadIdentities now runs only for true head triggers (and structural ticks, which re-baseline silently). Index-only bursts do zero head-identity reads. Adds deterministic tests: index-only bursts read zero head identities across linked and primary checkouts; logs/HEAD still refreshes identities for both; plus coalescing and debounce coverage. --- ...orktree-base-directory-change-collector.ts | 75 ++++--- ...rktree-base-directory-event-filter.test.ts | 96 ++++++-- .../worktree-base-directory-event-filter.ts | 70 ++++-- .../worktree-base-directory-watcher.test.ts | 210 ++++++++++++++++-- .../ipc/worktree-base-directory-watcher.ts | 102 +++++---- 5 files changed, 413 insertions(+), 140 deletions(-) diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index 829aa8c3b82..168b32b48ad 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -13,35 +13,55 @@ export type WorktreeBaseCollectedChanges = { overflow: boolean structureRepoIds: string[] gitStatusRepoIds: string[] + headIdentityRepoIds: string[] +} + +type ChangeBuckets = { + structureRepoIds: Set + gitStatusRepoIds: Set + headIdentityRepoIds: Set +} + +function emptyBuckets(): ChangeBuckets { + return { + structureRepoIds: new Set(), + gitStatusRepoIds: new Set(), + headIdentityRepoIds: new Set() + } } function emptyChanges(): WorktreeBaseCollectedChanges { - return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] } + return { + overflow: false, + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + } } function addMatchingChange( target: WorktreeBaseWatchTarget, event: WorktreeBaseWatcherEvent, - structureRepoIds: Set, - gitStatusRepoIds: Set + buckets: ChangeBuckets ): void { const change = classifyWorktreeBaseChange(target, event) for (const repoId of change.structureRepoIds) { - structureRepoIds.add(repoId) + buckets.structureRepoIds.add(repoId) } for (const repoId of change.gitStatusRepoIds) { - gitStatusRepoIds.add(repoId) + buckets.gitStatusRepoIds.add(repoId) + } + for (const repoId of change.headIdentityRepoIds) { + buckets.headIdentityRepoIds.add(repoId) } } -function toCollectedChanges( - structureRepoIds: Set, - gitStatusRepoIds: Set -): WorktreeBaseCollectedChanges { +function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChanges { return { overflow: false, - structureRepoIds: [...structureRepoIds], - gitStatusRepoIds: [...gitStatusRepoIds] + structureRepoIds: [...buckets.structureRepoIds], + gitStatusRepoIds: [...buckets.gitStatusRepoIds], + headIdentityRepoIds: [...buckets.headIdentityRepoIds] } } @@ -49,47 +69,30 @@ export function collectLocalWorktreeBaseChanges( target: WorktreeBaseWatchTarget, events: WorktreeBaseWatcherEvent[] ): WorktreeBaseCollectedChanges { - const structureRepoIds = new Set() - const gitStatusRepoIds = new Set() + const buckets = emptyBuckets() for (const event of events) { - addMatchingChange(target, event, structureRepoIds, gitStatusRepoIds) + addMatchingChange(target, event, buckets) } - return toCollectedChanges(structureRepoIds, gitStatusRepoIds) + return toCollectedChanges(buckets) } export function collectRemoteWorktreeBaseChanges( target: WorktreeBaseWatchTarget, events: FsChangeEvent[] ): WorktreeBaseCollectedChanges { - const structureRepoIds = new Set() - const gitStatusRepoIds = new Set() + const buckets = emptyBuckets() for (const event of events) { if (event.kind === 'overflow') { return { ...emptyChanges(), overflow: true } } if (event.kind === 'rename') { if (event.oldAbsolutePath) { - addMatchingChange( - target, - { type: 'delete', path: event.oldAbsolutePath }, - structureRepoIds, - gitStatusRepoIds - ) + addMatchingChange(target, { type: 'delete', path: event.oldAbsolutePath }, buckets) } - addMatchingChange( - target, - { type: 'create', path: event.absolutePath }, - structureRepoIds, - gitStatusRepoIds - ) + addMatchingChange(target, { type: 'create', path: event.absolutePath }, buckets) continue } - addMatchingChange( - target, - { type: event.kind, path: event.absolutePath }, - structureRepoIds, - gitStatusRepoIds - ) + addMatchingChange(target, { type: event.kind, path: event.absolutePath }, buckets) } - return toCollectedChanges(structureRepoIds, gitStatusRepoIds) + return toCollectedChanges(buckets) } diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts index 2a87b9f0b25..330be8b925c 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.test.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -25,13 +25,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'create', path: join(COMMON_DIR, 'worktrees', 'wt-b') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( matchingWorktreeBaseRepoIds(target, { type: 'delete', @@ -44,12 +52,26 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { const target = makeGitCommonTarget() for (const file of ['HEAD', 'packed-refs']) { expect( - classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, file) }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, file) + }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) } expect( - classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'index') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'index') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) }) it('classifies linked-worktree index as status-only', () => { @@ -59,16 +81,24 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index.lock') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) }) - it('classifies HEAD reflog appends as status-only for linked and primary checkouts', () => { + it('classifies HEAD reflog appends as head-identity triggers for linked and primary checkouts', () => { const target = makeGitCommonTarget() // commit --amend / reset --soft move HEAD without touching index or HEAD. expect( @@ -76,26 +106,42 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: ['repo-1'] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'logs', 'HEAD') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: ['repo-1'] + }) // Per-ref reflogs churn on fetches and stay ignored. expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'logs', 'refs', 'heads', 'main') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'refs', 'heads', 'main') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) }) it('classifies worktree-scoped config as structural for sparse-flag freshness', () => { @@ -105,13 +151,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'config.worktree') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'create', path: join(COMMON_DIR, 'config.worktree') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) }) it('classifies Windows-shaped linked metadata paths', () => { @@ -126,13 +180,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: win32.join(commonDir, 'worktrees', 'wt a', 'gitdir') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: win32.join(commonDir, 'worktrees', 'wt a', 'index') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) }) it('ignores non-status common-dir churn', () => { diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 7f161e33823..551af5a6568 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -11,6 +11,10 @@ type WorktreeBaseWatcherEvent = { export type WorktreeBaseChangeClass = { structureRepoIds: string[] gitStatusRepoIds: string[] + // logs/HEAD and other true head-move triggers: notify Source Control like + // status churn, but distinct so only these re-read head identities. An index + // rewrite cannot move HEAD, so it must never land here. + headIdentityRepoIds: string[] } export type WorktreeBaseWatchKind = 'base' | 'git-common' @@ -98,9 +102,10 @@ const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index']) const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree']) const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index']) -// `logs/HEAD` is the status-only trigger for head moves that rewrite no +// `logs/HEAD` is the head-identity trigger for head moves that rewrite no // watched leaf (commit --amend, reset --soft): every ref update through a -// checkout appends there, while `git status` churn never touches it. +// checkout appends there, while `git status` churn never touches it. It is +// kept separate from index churn so only these events re-read head identities. function isHeadLogParts(parts: string[], offset: number): boolean { return parts.length === offset + 2 && parts[offset] === 'logs' && parts[offset + 1] === 'HEAD' } @@ -109,6 +114,36 @@ function allRepoIds(target: WorktreeBaseWatchTarget): string[] { return [...target.repos.keys()] } +const NO_CHANGE: WorktreeBaseChangeClass = { + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] +} + +function structuralChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: repoIds, + gitStatusRepoIds: [], + headIdentityRepoIds: [] + } +} + +function gitStatusChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: [], + gitStatusRepoIds: repoIds, + headIdentityRepoIds: [] + } +} + +function headIdentityChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: repoIds + } +} + // Why: Git records linked worktrees under the common dir's `worktrees` // metadata, which is lower churn than watching checkout contents. function classifyGitCommonEvent( @@ -117,54 +152,49 @@ function classifyGitCommonEvent( ): WorktreeBaseChangeClass { const parts = pathRelativeToWorktreeWatchRoot(target.path, event.path) if (!parts) { - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } const repoIds = allRepoIds(target) if (parts.length === 1) { if (parts[0] === 'worktrees') { - return { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return structuralChange(repoIds) } if (GIT_COMMON_PRIMARY_STRUCTURAL_FILES.has(parts[0])) { - return { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return structuralChange(repoIds) } if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return gitStatusChange(repoIds) } - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } if (parts[0] !== 'worktrees') { if (isHeadLogParts(parts, 0)) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return headIdentityChange(repoIds) } - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } if (parts.length === 2) { - return event.type === 'update' - ? { structureRepoIds: [], gitStatusRepoIds: [] } - : { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return event.type === 'update' ? NO_CHANGE : structuralChange(repoIds) } if (parts.length === 3) { if (GIT_COMMON_LINKED_STRUCTURAL_FILES.has(parts[2])) { - return { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return structuralChange(repoIds) } if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return gitStatusChange(repoIds) } } if (isHeadLogParts(parts, 2)) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return headIdentityChange(repoIds) } - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } function classifyBaseEvent( target: WorktreeBaseWatchTarget, event: WorktreeBaseWatcherEvent ): WorktreeBaseChangeClass { - return { - structureRepoIds: matchingBaseRepoIds(target, event.path, event.type), - gitStatusRepoIds: [] - } + return structuralChange(matchingBaseRepoIds(target, event.path, event.type)) } export function classifyWorktreeBaseChange( diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 7f580421aaa..c560b5e51aa 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -116,7 +116,10 @@ describe('worktree base directory watcher', () => { emit(WORKTREE_ROOT, [ { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') }, - { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + { + type: 'create', + path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -133,7 +136,10 @@ describe('worktree base directory watcher', () => { ) emit(WORKTREE_ROOT, [ - { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + { + type: 'create', + path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') + } ]) destroyed = true await vi.advanceTimersByTimeAsync(300) @@ -145,7 +151,10 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(WORKTREE_ROOT, [ - { type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') } + { + type: 'update', + path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -156,7 +165,10 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') } + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -167,9 +179,18 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }, - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }, - { type: 'delete', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') } + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'delete', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -182,8 +203,14 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') }, - { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') } + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') + }, + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -192,17 +219,25 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled() }) - it('emits head identities for status-only head moves without structural fanout', async () => { + it('emits head identities for a linked reflog head move without structural fanout', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } ]) await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) await vi.advanceTimersByTimeAsync(0) // External commit --amend: only logs/HEAD moves, no index write. vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } ]) emit(PROJECT_GIT_COMMON_DIR, [ { @@ -217,50 +252,181 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1) expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ - { worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } ]) }) - it('does not emit head identities when a status-only burst leaves heads unchanged', async () => { + it('makes zero head-identity reads on an index-only burst across linked and primary checkouts', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } ]) await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) await vi.advanceTimersByTimeAsync(0) + // Subscribe baselines heads once; the index burst must add no further reads. + vi.mocked(readGitCommonHeadIdentities).mockClear() emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') } + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'index') } ]) await vi.advanceTimersByTimeAsync(300) await vi.advanceTimersByTimeAsync(0) - expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + // An index rewrite cannot move HEAD, so the linked-worktree scan is skipped. + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + // Source Control still refreshes for the status churn, coalesced to one call. + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('emits head identities for a primary-checkout reflog head move', async () => { + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { worktreePath: PROJECT_ROOT, head: 'aaa111', branch: 'refs/heads/main' } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { worktreePath: PROJECT_ROOT, head: 'bbb222', branch: 'refs/heads/main' } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'logs', 'HEAD') } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ + { + worktreePath: PROJECT_ROOT, + head: 'bbb222', + branch: 'refs/heads/main' + } + ]) + }) + + it('coalesces an index and reflog burst into one head read and one status refresh', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + // reset --soft rewrites the index and appends logs/HEAD in the same burst. + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('debounces successive reflog events into a single head read', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + const reflog = { + type: 'update' as const, + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + emit(PROJECT_GIT_COMMON_DIR, [reflog]) + // Second burst arrives before the debounce window elapses and resets it. + await vi.advanceTimersByTimeAsync(100) + emit(PROJECT_GIT_COMMON_DIR, [reflog]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) }) it('re-baselines head identities silently on structural notifications', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } ]) await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) await vi.advanceTimersByTimeAsync(0) // Branch switch: structural path owns the refresh via the full listing. vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'ccc333', branch: 'refs/heads/other' } + { + worktreePath: linkedWorktree, + head: 'ccc333', + branch: 'refs/heads/other' + } ]) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') } + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') + } ]) await vi.advanceTimersByTimeAsync(300) await vi.advanceTimersByTimeAsync(0) expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() - // A later status-only event diffs against the re-baselined heads. + // A later reflog event diffs against the re-baselined heads, so an + // unchanged head is not re-reported. emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') } + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } ]) await vi.advanceTimersByTimeAsync(300) await vi.advanceTimersByTimeAsync(0) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index d62eb2384ba..23352d688f1 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -25,6 +25,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { notifyTimer: ReturnType | null pendingStructureRepoIds: Set pendingGitStatusRepoIds: Set + pendingHeadIdentityRepoIds: Set headIdentityRefresh: WorktreeHeadIdentityRefreshState disposed: boolean } @@ -35,13 +36,17 @@ let syncGeneration = 0 let scheduledSync: ReturnType | null = null let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null -function scheduleNotification( - watch: ActiveWatch, - changes: { structureRepoIds?: readonly string[]; gitStatusRepoIds?: readonly string[] } -): void { +function clearPendingRepoIds(watch: ActiveWatch): void { + watch.pendingStructureRepoIds.clear() + watch.pendingGitStatusRepoIds.clear() + watch.pendingHeadIdentityRepoIds.clear() +} + +type PendingNotificationInput = Partial> + +function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationInput): void { if (watch.disposed || watch.mainWindow.isDestroyed()) { - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + clearPendingRepoIds(watch) return } for (const repoId of changes.structureRepoIds ?? []) { @@ -50,37 +55,41 @@ function scheduleNotification( for (const repoId of changes.gitStatusRepoIds ?? []) { watch.pendingGitStatusRepoIds.add(repoId) } + for (const repoId of changes.headIdentityRepoIds ?? []) { + watch.pendingHeadIdentityRepoIds.add(repoId) + } if (watch.notifyTimer) { clearTimeout(watch.notifyTimer) } watch.notifyTimer = setTimeout(() => { watch.notifyTimer = null if (watch.disposed || watch.mainWindow.isDestroyed()) { - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + clearPendingRepoIds(watch) return } const pendingStructure = [...watch.pendingStructureRepoIds] - const pendingGitStatus = [...watch.pendingGitStatusRepoIds].filter( - (repoId) => !watch.pendingStructureRepoIds.has(repoId) + const hasHeadIdentity = watch.pendingHeadIdentityRepoIds.size > 0 + // Source Control refreshes on both index churn and head moves; structural + // repos already refresh via the authoritative listing, so drop them here. + const sourceControlRepoIds = new Set( + [...watch.pendingGitStatusRepoIds, ...watch.pendingHeadIdentityRepoIds].filter( + (repoId) => !watch.pendingStructureRepoIds.has(repoId) + ) ) - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + // Structural ticks refresh silently (emit=false): the authoritative listing + // already reported them, so this only re-baselines ahead of later head diffs. + const emitHeadIdentities = pendingStructure.length === 0 + clearPendingRepoIds(watch) for (const repoId of pendingStructure) { notifyWorktreesChanged(watch.mainWindow, repoId) } - for (const repoId of pendingGitStatus) { + for (const repoId of sourceControlRepoIds) { notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId) } - if (supportsHeadIdentityRefresh(watch)) { - // Emit only when no structural notification fired this tick: structural - // paths already run the authoritative worktree listing; the silent pass - // just re-baselines so later status-only diffs stay accurate. - void refreshWorktreeHeadIdentities( - watch, - watch.headIdentityRefresh, - pendingStructure.length === 0 - ) + // Only re-read head identities for true head triggers: an index rewrite + // cannot move HEAD, so status-only bursts skip the linked-worktree scan. + if (supportsHeadIdentityRefresh(watch) && (pendingStructure.length > 0 || hasHeadIdentity)) { + void refreshWorktreeHeadIdentities(watch, watch.headIdentityRefresh, emitHeadIdentities) } }, WATCH_DEBOUNCE_MS) } @@ -92,7 +101,9 @@ function supportsHeadIdentityRefresh(watch: ActiveWatch): boolean { } function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean { - return changes.structureRepoIds.length > 0 || changes.gitStatusRepoIds.length > 0 + return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( + (ids) => ids.length > 0 + ) } function handleLocalWatchEvents( @@ -131,6 +142,24 @@ function handleRemoteWatchEvents( } } +function createActiveWatch( + target: WorktreeBaseWatchTarget, + mainWindow: BrowserWindow, + subscription: ActiveWatch['subscription'] +): ActiveWatch { + return { + ...target, + mainWindow, + subscription, + notifyTimer: null, + pendingStructureRepoIds: new Set(), + pendingGitStatusRepoIds: new Set(), + pendingHeadIdentityRepoIds: new Set(), + headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), + disposed: false + } +} + async function subscribeTarget( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow @@ -148,16 +177,9 @@ async function subscribeTarget( } handleRemoteWatchEvents(currentWatch, events) }) - activeWatch = { - ...target, - mainWindow, - subscription: { unsubscribe: async () => unwatch() }, - notifyTimer: null, - pendingStructureRepoIds: new Set(), - pendingGitStatusRepoIds: new Set(), - headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), - disposed: false - } + activeWatch = createActiveWatch(target, mainWindow, { + unsubscribe: async () => unwatch() + }) return activeWatch } @@ -176,16 +198,7 @@ async function subscribeTarget( handleLocalWatchEvents(currentWatch, null, events) } ) - activeWatch = { - ...target, - mainWindow, - subscription, - notifyTimer: null, - pendingStructureRepoIds: new Set(), - pendingGitStatusRepoIds: new Set(), - headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), - disposed: false - } + activeWatch = createActiveWatch(target, mainWindow, subscription) if (supportsHeadIdentityRefresh(activeWatch)) { // Baseline eagerly so the first status-only signal — possibly hours after // subscribe — diffs against subscribe-time heads instead of silently @@ -231,8 +244,7 @@ async function removeWatch(key: string): Promise { if (watch.notifyTimer) { clearTimeout(watch.notifyTimer) } - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + clearPendingRepoIds(watch) await watch.subscription.unsubscribe().catch((error) => { console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) }) From 13ed697cc215dbe706008daf17e84db22aa8d4e5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:40:09 -0700 Subject: [PATCH 4/6] Fix windows serve wsl barrier (#8559) * fix(startup): bound serve WSL reconciliation wait * test(startup): cover WSL barrier fail-open on early reconciliation rejection Co-authored-by: Orca * feat(serve): surface managed WSL reconciliation status to headless clients Expose reconciliation state ('pending'|'settled'|'failed') in the orca_server_ready payload and a wsl-cli-barrier startup milestone, so headless/SSH agents can tell the fail-open barrier outlived its budget and a WSL PTY launch may still race an un-migrated registration. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/index.ts | 20 ++++- .../startup/desktop-startup-ordering.test.ts | 26 ++++++- ...cli-reconciliation-startup-barrier.test.ts | 73 ++++++++++++++----- .../wsl-cli-reconciliation-startup-barrier.ts | 6 +- 4 files changed, 97 insertions(+), 28 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index b76964ae872..5196b7eac01 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -229,6 +229,10 @@ const recoveryReloadInFlight = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() let managedWslCliReconciliationReady: Promise = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = Promise.resolve() +// Why: the serve barrier fails open at its budget, so headless clients need the +// reconciliation state at ready time to know a WSL PTY launch may still race an +// un-migrated registration. 'settled' covers the off-Windows no-op fast path. +let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled' // Why: GPU child crashes clustered right after launch indicate a broken driver; // track them so Orca can move this build onto software rendering. const gpuLaunchTimeMs = Date.now() @@ -1384,6 +1388,9 @@ async function printServeReady(options: ServeOptions): Promise { type: 'orca_server_ready', runtimeId: runtime.getRuntimeId(), endpoint, + // Why: the WSL reconciliation barrier fails open, so 'pending' warns clients + // it outlived the startup budget and a WSL PTY launch may still race a repair. + managedWslCliReconciliation: managedWslCliReconciliationStatus, pairing: pairing.available ? { url: pairing.pairingUrl, @@ -1610,6 +1617,7 @@ app.whenReady().then(async () => { // Why: managed WSL launchers live outside the Windows app bundle, so keep // their launcher and bridge contract synchronized across app updates. + managedWslCliReconciliationStatus = 'pending' managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ isPackaged: app.isPackaged, userDataPath: getCanonicalUserDataPath(), @@ -1625,8 +1633,10 @@ app.whenReady().then(async () => { console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) } } + managedWslCliReconciliationStatus = 'settled' }) .catch((error) => { + managedWslCliReconciliationStatus = 'failed' console.warn( '[wsl-cli] Managed registration reconciliation discovery failed:', error instanceof Error ? error.message : String(error) @@ -2090,9 +2100,13 @@ app.whenReady().then(async () => { } if (serveOptions) { - // Why: headless serve has no renderer startup barrier, so settle managed - // WSL command reconciliation before exposing its runtime transport. - await managedWslCliReconciliationReady + // Why: give managed WSL launchers a brief chance to migrate before headless + // PTYs become reachable without letting slow repairs withhold all RPC readiness. + logStartupMilestone('wsl-cli-barrier-start') + await managedWslCliStartupBarrierReady + logStartupMilestone('wsl-cli-barrier-resolved', { + reconciliation: managedWslCliReconciliationStatus + }) await startServeAgentHookServer() registerHeadlessPtyRuntime( runtime, diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index d99177285af..b9adfc6b8f2 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -describe('desktop startup ordering', () => { +describe('startup ordering', () => { it('passes the startup barrier into PTY handlers without blocking window creation', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachStart = source.indexOf('attachMainWindowServices(') @@ -24,7 +24,7 @@ describe('desktop startup ordering', () => { expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) }) - it('shows the desktop window without waiting for WSL registration reconciliation', () => { + it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart) @@ -43,12 +43,32 @@ describe('desktop startup ordering', () => { expect(serveStart).toBeGreaterThan(reconciliationStart) expect(serveEnd).toBeGreaterThan(serveStart) expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) - expect(serveStartup).toContain('await managedWslCliReconciliationReady') + expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') + expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') + expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( + serveStartup.indexOf('await runtimeRpc.start()') + ) expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') expect(barrier).toContain('managedWslCliStartupBarrierReady') expect(barrier).not.toContain('managedWslCliReconciliationReady') }) + it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + // Why: the barrier fails open, so the serve-ready payload must carry the + // reconciliation state and the bounded wait must be traceable via a milestone. + const readyStart = source.indexOf("type: 'orca_server_ready'") + const readyEnd = source.indexOf('pairing: pairing.available', readyStart) + const readyPayload = source.slice(readyStart, readyEnd) + expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus') + + expect(source).toContain("managedWslCliReconciliationStatus = 'pending'") + expect(source).toContain("managedWslCliReconciliationStatus = 'settled'") + expect(source).toContain("managedWslCliReconciliationStatus = 'failed'") + expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") + }) + it('does not run the rate-limit quota fetch before the first window can show results', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachIndex = source.indexOf('rateLimits.attach(window)') diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts index e59201e6a02..b2a56a415e8 100644 --- a/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts @@ -30,13 +30,13 @@ describe('createWslCliReconciliationStartupBarrier', () => { } }) - it('fails open when reconciliation exceeds the startup budget', async () => { + it('fails open immediately when reconciliation rejects before the budget expires', async () => { vi.useFakeTimers() - let resolveReconciliation!: () => void + let rejectReconciliation!: (error: Error) => void try { - const reconciliation = new Promise((resolve) => { - resolveReconciliation = resolve + const reconciliation = new Promise((_resolve, reject) => { + rejectReconciliation = reject }) const barrier = createWslCliReconciliationStartupBarrier(reconciliation) let barrierSettled = false @@ -44,38 +44,73 @@ describe('createWslCliReconciliationStartupBarrier', () => { barrierSettled = true }) - await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1) + await vi.advanceTimersByTimeAsync(1) expect(barrierSettled).toBe(false) - await vi.advanceTimersByTimeAsync(1) + // Why: a fast WSL discovery failure should release the barrier via its catch + // branch without waiting out the budget, and must clear the pending timer. + rejectReconciliation(new Error('WSL discovery failed')) await expect(barrier).resolves.toBeUndefined() - resolveReconciliation() - await reconciliation + expect(vi.getTimerCount()).toBe(0) } finally { vi.useRealTimers() } }) - it('leaves reconciliation running after the startup budget expires', async () => { + it('lets serve reach RPC readiness at budget while reconciliation remains pending', async () => { vi.useFakeTimers() - let resolveWork!: () => void - let completed = false + let resolveReconciliation!: () => void + let reconciliationCompleted = false try { - const work = new Promise((resolve) => { - resolveWork = resolve + const reconciliation = new Promise((resolve) => { + resolveReconciliation = resolve }).then(() => { - completed = true + reconciliationCompleted = true }) - const barrier = createWslCliReconciliationStartupBarrier(work, { timeoutMs: 10 }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let rpcReady = false + const serveRpcReadiness = barrier.then(() => { + rpcReady = true + }) + + await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1) + expect(rpcReady).toBe(false) + expect(reconciliationCompleted).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(serveRpcReadiness).resolves.toBeUndefined() + expect(rpcReady).toBe(true) + expect(reconciliationCompleted).toBe(false) + + resolveReconciliation() + await reconciliation + expect(reconciliationCompleted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('preserves eventual reconciliation error reporting after the budget expires', async () => { + vi.useFakeTimers() + let rejectReconciliation!: (error: Error) => void + const reportedErrors: string[] = [] + + try { + const reconciliation = new Promise((_resolve, reject) => { + rejectReconciliation = reject + }).catch((error) => { + reportedErrors.push(error instanceof Error ? error.message : String(error)) + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation, { timeoutMs: 10 }) await vi.advanceTimersByTimeAsync(10) await expect(barrier).resolves.toBeUndefined() - expect(completed).toBe(false) + expect(reportedErrors).toEqual([]) - resolveWork() - await work - expect(completed).toBe(true) + rejectReconciliation(new Error('WSL discovery failed')) + await reconciliation + expect(reportedErrors).toEqual(['WSL discovery failed']) } finally { vi.useRealTimers() } diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts index 5f70ad7eeb0..056f225b8de 100644 --- a/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts @@ -5,7 +5,7 @@ type WslCliReconciliationStartupBarrierOptions = { } /** - * Briefly gates restored terminals while managed WSL registrations reconcile. + * Briefly gates terminal startup while managed WSL registrations reconcile. */ export function createWslCliReconciliationStartupBarrier( reconciliation: Promise, @@ -22,8 +22,8 @@ export function createWslCliReconciliationStartupBarrier( } }) - // Why: reconciliation may outlive a slow or unavailable WSL distro; restored - // terminals should wait briefly without turning WSL discovery into an app hang. + // Why: reconciliation may outlive a slow or unavailable WSL distro; terminal + // startup should wait briefly without turning WSL discovery into an app hang. return Promise.race([ settled, new Promise((resolve) => { From 9a77f56671957d145892acf4935ce22d0e413f5f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:41:58 +0000 Subject: [PATCH 5/6] release: v1.4.138-rc.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a062d03ca1f..dff61b9eed2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.138-rc.6", + "version": "1.4.138-rc.7", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 7b3103f8909848a35934c2749607c50f70485963 Mon Sep 17 00:00:00 2001 From: fsdwen Date: Mon, 13 Jul 2026 21:50:18 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(i18n):=20correct=20'run'=20translations?= =?UTF-8?q?=20from=20'=E8=B7=91=E6=AD=A5'=20to=20'=E6=89=A7=E8=A1=8C'=20in?= =?UTF-8?q?=20zh.json=20(#8554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #8553 '跑步' (jogging) was used in computing/automation contexts where the correct term is '执行' (execute). 11 entries fixed across: - TabBarQuickCommandsButton: Run - Scripts settings: When to run - quick.commands.search: run (keyword tag) - ReviewPRViewAnimatedVisual: Running - AutomationRunHistory: Run (column header) - AutomationRunPageFrame: Back to runs - AutomationsPage: Runs, run - ExternalAutomationManagers: run - ExternalAutomationRunTable: Runs, run Note: key e66091daf4 already correctly translated as '运行'. --- src/renderer/src/i18n/locales/zh.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dbf29920404..44e1e44a579 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2818,7 +2818,7 @@ "e8e1a52edb": "删除“{{value0}}”?", "37e1bb90ce": "运行:{{value0}}", "77ac113df0": "开始 {{value0}}: {{value1}}", - "7b1c9d6ae1": "跑步", + "7b1c9d6ae1": "执行", "c781f992e4": "destructive", "be8f0ff166": "删除", "f3a8c2d1e7": "搜索快捷命令...", @@ -5967,7 +5967,7 @@ "b91a0f297d": "在归档工作树之前运行的本地和共享脚本。", "9a100323ff": "存档脚本", "21fb607a87": "创建新工作树时的默认行为。", - "793dcee97d": "何时跑步", + "793dcee97d": "何时执行", "63e1783173": "当安装脚本可用时选择默认行为。", "fb6bebcf7e": "何时运行安装程序", "30d555acd2": "创建新工作树后运行的本地和共享脚本。", @@ -8028,7 +8028,7 @@ "b86c727100": "新项目管理", "b949a7c0a0": "PNPM", "0b78c4a165": "启动", - "2d8aff42be": "跑步", + "2d8aff42be": "执行", "1c5bdcd0f2": "存储库", "89d2a9ad9f": "仓库", "f58b92a48f": "项目", @@ -10915,7 +10915,7 @@ "ca36f7b27c": "通过", "25f6838e43": "Lint", "2ef0b97954": "类型检查", - "8ed213397c": "跑步", + "8ed213397c": "执行中", "d340c052fb": "核实", "9a097cae12": "1 待定", "2f37142229": "压缩合并", @@ -12343,14 +12343,14 @@ "13988187b3": "token", "86a248187e": "花费", "149c0b49c7": "工作区", - "8faaa00726": "跑步", + "8faaa00726": "执行", "53fc5f07ab": "运行历史", "a00e38d1a3": "不适用", "fdb3caa8fb": "已知的" }, "AutomationRunPageFrame": { "40a511bed4": "运行上下文", - "33741dd973": "返回跑步" + "33741dd973": "返回执行" }, "AutomationSchedulePicker": { "22359b186a": "上午或下午", @@ -12374,7 +12374,7 @@ "2695883141": "删除", "c3a28c9793": "选择一个自动化来查看运行。", "295698292f": "重新运行", - "0e110a3469": "跑步", + "0e110a3469": "执行", "bb1b2cd31e": "概述", "97ff587ee3": "连接此源以检查远程配置文件中的 Hermes 自动化。", "aaa007846f": "来源不可用", @@ -12437,7 +12437,7 @@ "dd0bc7a1ba": "每次运行新的", "7b2e285552": "SSH 连接在此客户端中不可用。", "d441032f7e": "暂停", - "5918020edc": "跑步", + "5918020edc": "执行", "a21f6c33ad": "自动化来源已刷新。", "53f06f0ad5": "重试来源", "pendingAutomationMissing": "自动化已不可用。", @@ -12468,7 +12468,7 @@ "766abf833c": "Hermes", "bf5f67b590": "爱马仕", "e66091daf4": "运行", - "8e9165af08": "跑步", + "8e9165af08": "执行", "2b0adbce21": "已暂停", "b3feba84c7": "当前", "92405f1431": "不可用", @@ -12485,11 +12485,11 @@ "be551397ca": "状态", "a813df9808": "预览", "d4b34feb66": "运行时间", - "2d4388a908": "跑步", + "2d4388a908": "执行", "9c080765ff": "尚未找到 Hermes 运行信息。", "8ea934cacf": "正在加载运行...", "d5527d8fe7": "运行", - "872d032d05": "跑步" + "872d032d05": "执行" }, "HermesCronOutputView": { "e27c716b43": "提示词",