mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
# Conflicts: # config/reliability-gates.jsonc # src/main/ipc/pty.ts
This commit is contained in:
@@ -4062,14 +4062,14 @@
|
||||
"providers": ["local", "remote-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "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",
|
||||
@@ -4181,6 +4181,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",
|
||||
@@ -4291,11 +4293,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.",
|
||||
@@ -4304,11 +4306,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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+17
-3
@@ -229,6 +229,10 @@ const recoveryReloadInFlight = createWebContentsTimedFlag()
|
||||
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
|
||||
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
|
||||
let managedWslCliStartupBarrierReady: Promise<void> = 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<void> {
|
||||
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,
|
||||
|
||||
@@ -1757,6 +1757,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,
|
||||
@@ -1856,10 +1857,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 () => {
|
||||
@@ -1914,6 +1918,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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5379,10 +5390,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 () => {
|
||||
@@ -5411,7 +5425,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 () => {
|
||||
@@ -6982,10 +7002,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',
|
||||
|
||||
+14
-2
@@ -1324,8 +1324,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) {
|
||||
@@ -3747,6 +3753,9 @@ export function registerPtyHandlers(
|
||||
args.tabId.length <= 512 &&
|
||||
metadataLeafId !== null
|
||||
? { tabId: args.tabId, leafId: metadataLeafId }
|
||||
: undefined,
|
||||
!args.connectionId
|
||||
? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
@@ -4818,6 +4827,9 @@ export function registerPtyHandlers(
|
||||
args.tabId.length <= 512 &&
|
||||
metadataLeafId !== null
|
||||
? { tabId: args.tabId, leafId: metadataLeafId }
|
||||
: undefined,
|
||||
!args.connectionId
|
||||
? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,35 +13,55 @@ export type WorktreeBaseCollectedChanges = {
|
||||
overflow: boolean
|
||||
structureRepoIds: string[]
|
||||
gitStatusRepoIds: string[]
|
||||
headIdentityRepoIds: string[]
|
||||
}
|
||||
|
||||
type ChangeBuckets = {
|
||||
structureRepoIds: Set<string>
|
||||
gitStatusRepoIds: Set<string>
|
||||
headIdentityRepoIds: Set<string>
|
||||
}
|
||||
|
||||
function emptyBuckets(): ChangeBuckets {
|
||||
return {
|
||||
structureRepoIds: new Set<string>(),
|
||||
gitStatusRepoIds: new Set<string>(),
|
||||
headIdentityRepoIds: new Set<string>()
|
||||
}
|
||||
}
|
||||
|
||||
function emptyChanges(): WorktreeBaseCollectedChanges {
|
||||
return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] }
|
||||
return {
|
||||
overflow: false,
|
||||
structureRepoIds: [],
|
||||
gitStatusRepoIds: [],
|
||||
headIdentityRepoIds: []
|
||||
}
|
||||
}
|
||||
|
||||
function addMatchingChange(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
event: WorktreeBaseWatcherEvent,
|
||||
structureRepoIds: Set<string>,
|
||||
gitStatusRepoIds: Set<string>
|
||||
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<string>,
|
||||
gitStatusRepoIds: Set<string>
|
||||
): 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<string>()
|
||||
const gitStatusRepoIds = new Set<string>()
|
||||
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<string>()
|
||||
const gitStatusRepoIds = new Set<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
|
||||
notifyTimer: ReturnType<typeof setTimeout> | null
|
||||
pendingStructureRepoIds: Set<string>
|
||||
pendingGitStatusRepoIds: Set<string>
|
||||
pendingHeadIdentityRepoIds: Set<string>
|
||||
headIdentityRefresh: WorktreeHeadIdentityRefreshState
|
||||
disposed: boolean
|
||||
}
|
||||
@@ -35,13 +36,17 @@ let syncGeneration = 0
|
||||
let scheduledSync: ReturnType<typeof setTimeout> | 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<Omit<WorktreeBaseCollectedChanges, 'overflow'>>
|
||||
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
const env: Record<string, string> = {
|
||||
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')
|
||||
|
||||
@@ -24,6 +24,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): 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',
|
||||
|
||||
@@ -472,7 +472,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,
|
||||
@@ -980,6 +982,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
|
||||
@@ -5592,7 +5597,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.
|
||||
@@ -5603,6 +5609,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
|
||||
@@ -7297,6 +7304,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
|
||||
@@ -20723,7 +20751,14 @@ export class OrcaRuntimeService {
|
||||
state: Partial<
|
||||
Pick<
|
||||
RuntimePtyWorktreeRecord,
|
||||
'connected' | 'lastOutputAt' | 'preview' | 'tabId' | 'paneKey' | 'title' | 'connectionId'
|
||||
| 'connected'
|
||||
| 'lastOutputAt'
|
||||
| 'preview'
|
||||
| 'tabId'
|
||||
| 'paneKey'
|
||||
| 'title'
|
||||
| 'connectionId'
|
||||
| 'isWsl'
|
||||
>
|
||||
> = {}
|
||||
): RuntimePtyWorktreeRecord {
|
||||
@@ -20734,6 +20769,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,
|
||||
@@ -20775,6 +20811,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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
resolveReconciliation = resolve
|
||||
const reconciliation = new Promise<void>((_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<void>((resolve) => {
|
||||
resolveWork = resolve
|
||||
const reconciliation = new Promise<void>((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<void>((_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()
|
||||
}
|
||||
|
||||
@@ -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<unknown>,
|
||||
@@ -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<void>((resolve) => {
|
||||
|
||||
@@ -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<typeof vi.fn<() => Promise<void>>>
|
||||
}
|
||||
|
||||
type InstalledSupervisorWatch = InstalledWatch & { dir: string }
|
||||
|
||||
function stubRelayWatcherSupervisors() {
|
||||
const installed = new Map<WatcherProcessSupervisor, InstalledSupervisorWatch[]>()
|
||||
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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "提示词",
|
||||
|
||||
Reference in New Issue
Block a user