mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/global-worktree-visibility-defaults
This commit is contained in:
@@ -26,7 +26,8 @@ export class ClaudeAgentTeamsService {
|
||||
leaderHandle: string
|
||||
baseEnv: Record<string, string | undefined>
|
||||
shimDir: string
|
||||
shimBin: string
|
||||
/** Absolute path only; null leaves the var unset so the shim refuses to guess a cwd-relative CLI. */
|
||||
shimBin: string | null
|
||||
}): AgentTeamsLaunchEnv {
|
||||
const teamId = `team-${randomUUID()}`
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
@@ -47,8 +48,10 @@ export class ClaudeAgentTeamsService {
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: teamId,
|
||||
ORCA_AGENT_TEAMS_TOKEN: token,
|
||||
ORCA_AGENT_TEAMS_LEADER_PANE: leaderPane,
|
||||
ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir,
|
||||
ORCA_AGENT_TEAMS_SHIM_BIN: args.shimBin
|
||||
ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir
|
||||
}
|
||||
if (args.shimBin) {
|
||||
env.ORCA_AGENT_TEAMS_SHIM_BIN = args.shimBin
|
||||
}
|
||||
if (args.baseEnv.ORCA_PAIRING_CODE) {
|
||||
env.ORCA_PAIRING_CODE = args.baseEnv.ORCA_PAIRING_CODE
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -5,7 +7,8 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildClaudeAgentTeamsLaunchPlan,
|
||||
ensureClaudeAgentTeamsShimDir,
|
||||
resolveClaudeAgentTeamsShimBin
|
||||
resolveClaudeAgentTeamsShimBin,
|
||||
windowsClaudeAgentTeamsShimScript
|
||||
} from './claude-agent-teams-shim-env'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -88,4 +91,105 @@ describe('claude agent teams shim env', () => {
|
||||
|
||||
expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath)
|
||||
})
|
||||
|
||||
it('refuses to resolve a CLI through relative PATH entries or a bare override', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-'))
|
||||
roots.push(root)
|
||||
for (const name of ['orca', 'orca-ide', 'orca.cmd']) {
|
||||
const path = join(root, name)
|
||||
await writeFile(path, '#!/usr/bin/env sh\n', 'utf8')
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(path, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
expect(resolveClaudeAgentTeamsShimBin({ PATH: '.' })).toBeNull()
|
||||
expect(resolveClaudeAgentTeamsShimBin({ PATH: '' })).toBeNull()
|
||||
expect(
|
||||
resolveClaudeAgentTeamsShimBin({ PATH: '.', ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' })
|
||||
).toBeNull()
|
||||
// Why: a bare override is still honored when it maps to a real absolute PATH entry.
|
||||
expect(resolveClaudeAgentTeamsShimBin({ PATH: root, ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' })).toBe(
|
||||
join(root, 'orca')
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'resolves through the Windows `Path` env spelling',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-'))
|
||||
roots.push(root)
|
||||
const cliPath = join(root, 'orca.cmd')
|
||||
await writeFile(cliPath, '@echo off\r\n', 'utf8')
|
||||
|
||||
expect(resolveClaudeAgentTeamsShimBin({ Path: root })).toBe(cliPath)
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back to in-process teammates when no absolute CLI can be qualified', async () => {
|
||||
const createTeamEnv = (): Record<string, string> => {
|
||||
throw new Error('native shim env must not be built without a qualified CLI')
|
||||
}
|
||||
|
||||
await expect(
|
||||
buildClaudeAgentTeamsLaunchPlan({
|
||||
command: 'claude',
|
||||
mode: 'native-panes-shim',
|
||||
baseEnv: { PATH: '.' },
|
||||
createTeamEnv
|
||||
})
|
||||
).resolves.toEqual({
|
||||
command: 'claude --teammate-mode in-process',
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'never runs a cwd-resolved orca when the shim bin is unqualified',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-'))
|
||||
roots.push(root)
|
||||
await ensureClaudeAgentTeamsShimDir(root)
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cwd-'))
|
||||
roots.push(cwd)
|
||||
const marker = join(cwd, 'hijacked')
|
||||
for (const name of ['orca', 'orca-ide']) {
|
||||
const decoy = join(cwd, name)
|
||||
await writeFile(decoy, `#!/usr/bin/env sh\ntouch ${JSON.stringify(marker)}\n`, 'utf8')
|
||||
await chmod(decoy, 0o755)
|
||||
}
|
||||
|
||||
const hijack = spawnSync(join(root, 'tmux'), ['display-message', '-p', '#{pane_id}'], {
|
||||
cwd,
|
||||
env: { PATH: `.:${process.env.PATH ?? ''}` },
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(hijack.status).toBe(127)
|
||||
expect(hijack.stderr).toContain('absolute path')
|
||||
expect(existsSync(marker)).toBe(false)
|
||||
|
||||
const cli = join(cwd, 'fake-orca')
|
||||
await writeFile(cli, '#!/usr/bin/env sh\necho "ran $*"\n', 'utf8')
|
||||
await chmod(cli, 0o755)
|
||||
const qualified = spawnSync(join(root, 'tmux'), ['list-panes'], {
|
||||
cwd,
|
||||
env: { PATH: `.:${process.env.PATH ?? ''}`, ORCA_AGENT_TEAMS_SHIM_BIN: cli },
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(qualified.status).toBe(0)
|
||||
expect(qualified.stdout.trim()).toBe('ran agent-teams-tmux list-panes')
|
||||
}
|
||||
)
|
||||
|
||||
it('writes a Windows shim that rejects an unqualified shim bin', () => {
|
||||
const script = windowsClaudeAgentTeamsShimScript()
|
||||
|
||||
expect(script).not.toMatch(/^set "ORCA_AGENT_TEAMS_SHIM_BIN=orca/m)
|
||||
expect(script).toContain('if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run')
|
||||
// Why: `call` would re-expand `%2`-style tmux pane args as batch parameters.
|
||||
expect(script).toContain('\r\n"%ORCA_SHIM_BIN%" agent-teams-tmux %*\r\n')
|
||||
expect(script).toContain('exit /b 127')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { accessSync, constants, existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { delimiter, dirname, isAbsolute, join } from 'node:path'
|
||||
import {
|
||||
addClaudeTeammateModeAuto,
|
||||
addClaudeTeammateModeInProcess,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ClaudeAgentTeamsMode
|
||||
} from '../../shared/claude-agent-teams-tmux-compat'
|
||||
import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name'
|
||||
import { resolvePathEnvKey } from '../pty/windows-path-segment-merge'
|
||||
|
||||
export type ClaudeAgentTeamsLaunchPlan = {
|
||||
command: string
|
||||
@@ -20,7 +21,7 @@ export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): P
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeIfChanged(join(root, 'tmux'), unixShimScript())
|
||||
if (process.platform === 'win32') {
|
||||
await writeIfChanged(join(root, 'tmux.cmd'), windowsShimScript())
|
||||
await writeIfChanged(join(root, 'tmux.cmd'), windowsClaudeAgentTeamsShimScript())
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -41,8 +42,15 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: {
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
|
||||
}
|
||||
}
|
||||
const shimDir = await ensureClaudeAgentTeamsShimDir()
|
||||
const shimBin = resolveClaudeAgentTeamsShimBin(args.baseEnv)
|
||||
if (!shimBin) {
|
||||
// Why: without an absolute CLI path the shim would resolve a bare `orca` against the pane cwd, so degrade instead.
|
||||
return {
|
||||
command: addClaudeTeammateModeInProcess(args.command),
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
|
||||
}
|
||||
}
|
||||
const shimDir = await ensureClaudeAgentTeamsShimDir()
|
||||
const env = args.createTeamEnv(shimDir, shimBin)
|
||||
return {
|
||||
command: addClaudeTeammateModeAuto(args.command),
|
||||
@@ -51,20 +59,27 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: {
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute path to the Orca CLI that backs the tmux shim, or null when none can be qualified. */
|
||||
export function resolveClaudeAgentTeamsShimBin(
|
||||
env: Record<string, string | undefined> = process.env
|
||||
): string {
|
||||
if (env.ORCA_AGENT_TEAMS_SHIM_BIN) {
|
||||
return env.ORCA_AGENT_TEAMS_SHIM_BIN
|
||||
): string | null {
|
||||
// Why: Windows callers pass an env spelt `Path`; reading only `PATH` there would find no CLI at all.
|
||||
const pathValue = env[resolvePathEnvKey(env, process.platform)]
|
||||
const override = env.ORCA_AGENT_TEAMS_SHIM_BIN
|
||||
if (override) {
|
||||
// Why: a bare override name would be resolved by the shim's shell against its cwd, so qualify it or ignore it.
|
||||
const qualified = isAbsolute(override) ? override : findExecutableOnPath(override, pathValue)
|
||||
if (qualified) {
|
||||
return qualified
|
||||
}
|
||||
}
|
||||
const bundled = bundledLauncherPath()
|
||||
if (bundled && isExecutableFile(bundled)) {
|
||||
return bundled
|
||||
}
|
||||
return (
|
||||
findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ??
|
||||
findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), env.PATH) ??
|
||||
getOrcaCliCommandNameForPlatform(process.platform)
|
||||
findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', pathValue) ??
|
||||
findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), pathValue)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +105,8 @@ function bundledLauncherPath(): string | null {
|
||||
|
||||
function findExecutableOnPath(command: string, pathValue: string | undefined): string | null {
|
||||
for (const directory of pathValue?.split(delimiter) ?? []) {
|
||||
if (!directory) {
|
||||
// Why: empty and relative PATH entries resolve against a cwd we do not control, which is the hijack we are avoiding.
|
||||
if (!directory || !isAbsolute(directory)) {
|
||||
continue
|
||||
}
|
||||
const candidate = join(directory, command)
|
||||
@@ -113,23 +129,42 @@ function isExecutableFile(candidate: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: an unqualified command name is resolved against the invoking pane's cwd (always on cmd.exe, and via `.`/empty
|
||||
// PATH entries on POSIX), so a stray `orca` next to the agent's files would run with the team token. Demand a
|
||||
// fully-qualified binary instead of guessing one.
|
||||
function unixShimScript(): string {
|
||||
return [
|
||||
'#!/usr/bin/env sh',
|
||||
'set -eu',
|
||||
`exec "\${ORCA_AGENT_TEAMS_SHIM_BIN:-${getOrcaCliCommandNameForPlatform(process.platform)}}" agent-teams-tmux "$@"`,
|
||||
'orca_bin=${ORCA_AGENT_TEAMS_SHIM_BIN:-}',
|
||||
'case $orca_bin in',
|
||||
' /*|[A-Za-z]:[\\\\/]*) ;;',
|
||||
' *)',
|
||||
' echo "orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path" >&2',
|
||||
' exit 127',
|
||||
' ;;',
|
||||
'esac',
|
||||
'exec "$orca_bin" agent-teams-tmux "$@"',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function windowsShimScript(): string {
|
||||
export function windowsClaudeAgentTeamsShimScript(): string {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (',
|
||||
` set "ORCA_AGENT_TEAMS_SHIM_BIN=${getOrcaCliCommandNameForPlatform(process.platform)}"`,
|
||||
')',
|
||||
'"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*',
|
||||
'set "ORCA_SHIM_BIN=%ORCA_AGENT_TEAMS_SHIM_BIN%"',
|
||||
'if not defined ORCA_SHIM_BIN goto :unqualified',
|
||||
'if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run',
|
||||
'if "%ORCA_SHIM_BIN:~0,2%"=="\\\\" goto :run',
|
||||
'goto :unqualified',
|
||||
':run',
|
||||
// Why: no `call` — its extra percent-expansion pass would rewrite tmux pane args such as `%2` into batch parameters.
|
||||
'"%ORCA_SHIM_BIN%" agent-teams-tmux %*',
|
||||
'exit /b %ERRORLEVEL%',
|
||||
':unqualified',
|
||||
'echo orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path 1>&2',
|
||||
'exit /b 127',
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
@@ -14280,6 +14280,8 @@ describe('OrcaRuntimeService', () => {
|
||||
command: 'claude --resume claude-session',
|
||||
env: {
|
||||
CLAUDE_PROFILE: 'captured',
|
||||
// Why: native panes need an absolute CLI; without one the plan degrades to in-process teammates.
|
||||
ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team',
|
||||
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
|
||||
TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1'
|
||||
@@ -14290,6 +14292,7 @@ describe('OrcaRuntimeService', () => {
|
||||
agentArgs: '--teammate-mode auto',
|
||||
agentEnv: {
|
||||
CLAUDE_PROFILE: 'captured',
|
||||
ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team',
|
||||
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
|
||||
TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1'
|
||||
@@ -14304,6 +14307,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(spawnCall?.env).toMatchObject({
|
||||
CLAUDE_PROFILE: 'captured',
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide',
|
||||
TMUX_PANE: '%1'
|
||||
})
|
||||
expect(spawnCall?.env?.ORCA_AGENT_TEAMS_TEAM_ID).toMatch(/^team-/)
|
||||
|
||||
@@ -29496,7 +29496,6 @@ export class OrcaRuntimeService {
|
||||
if (!this.store) {
|
||||
return { worktrees: [], platformByRepoId: new Map() }
|
||||
}
|
||||
const now = Date.now()
|
||||
const metaById = this.store.getAllWorktreeMeta() ?? {}
|
||||
const repos = this.store.getRepos()
|
||||
const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos)
|
||||
@@ -29577,11 +29576,13 @@ export class OrcaRuntimeService {
|
||||
this.store?.getAllWorktreeLineage?.() ?? {}
|
||||
)
|
||||
// Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca.
|
||||
// Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an
|
||||
// already-expired entry, so the very next poll recomputes and every caller repeats the same slow path.
|
||||
if (generation === this.resolvedWorktreeGeneration) {
|
||||
this.resolvedWorktreeCache = {
|
||||
worktrees,
|
||||
platformByRepoId,
|
||||
expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS
|
||||
expiresAt: Date.now() + RESOLVED_WORKTREE_CACHE_TTL_MS
|
||||
}
|
||||
}
|
||||
return { worktrees, platformByRepoId }
|
||||
@@ -36103,13 +36104,20 @@ const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000
|
||||
// edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size
|
||||
// stamp, so a real scan still runs on this interval even while the probe reports "unchanged".
|
||||
export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000
|
||||
// Why so generous: a healthy but slow host (100+ linked worktrees, Windows Defender, cold dentry
|
||||
// cache, cloud placeholders) must still get to reuse its scan. Well under WORKTREE_LIST_TIMEOUT_MS,
|
||||
// and expiring yields `null` — the existing "cannot prove unchanged" sentinel, so a real scan runs.
|
||||
// Exceeding RESOLVED_WORKTREE_REPO_TIMEOUT_MS is deliberate, not a bug: the payoff is skipping a
|
||||
// `git worktree list` subprocess, not cutting this caller's latency — that caller is already capped.
|
||||
export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS = 10_000
|
||||
const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
|
||||
export const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
|
||||
// Why reserved rather than spent on the probe: when the probe expires the caller still has to run
|
||||
// `git worktree list` and answer inside the same budget, so the fallback needs its own room. Sized
|
||||
// for a healthy Git on a busy host, well above the tens of milliseconds a warm list costs.
|
||||
const WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS = 1500
|
||||
// Why derived from the caller's budget instead of a generous absolute: this wait runs *inside*
|
||||
// RESOLVED_WORKTREE_REPO_TIMEOUT_MS, so outlasting it buys nothing — the caller has already given up
|
||||
// and restored persisted rows — while turning a reusable scan into a full-budget stall that repeats
|
||||
// on every TTL expiry. Subtracting keeps that invariant true by construction if either side moves.
|
||||
// Why not smaller: the probe reads a subset of what the fallback scan reads, so a probe too slow to
|
||||
// fit is a scan that will not fit either — waiting is strictly better right up to the budget.
|
||||
// Expiring yields `null`, the existing "cannot prove unchanged" sentinel, so a real scan runs.
|
||||
export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS =
|
||||
RESOLVED_WORKTREE_REPO_TIMEOUT_MS - WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS
|
||||
|
||||
export function resolveWorktreeScanCacheTtlMs(repo: Pick<Repo, 'path' | 'connectionId'>): number {
|
||||
return !repo.connectionId && isAgentScratchRepoRootPath(repo.path)
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({
|
||||
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
RESOLVED_WORKTREE_REPO_TIMEOUT_MS,
|
||||
WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS,
|
||||
WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS
|
||||
} from './orca-runtime'
|
||||
@@ -49,6 +50,10 @@ const WORKTREE_PATH = '/Users/me/dev/app-feature'
|
||||
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
|
||||
const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}`
|
||||
const SCAN_TTL_MS = 30_000
|
||||
// A busy host (100+ linked worktrees, Defender, cold dentry cache, cloud placeholders) can spend
|
||||
// seconds in pure stat time. Reuse has to survive that, or trimming the probe's deadline to fit the
|
||||
// caller budget just trades a repeating stall for a repeating `git worktree list`.
|
||||
const SLOW_BUT_HEALTHY_PROBE_MS = 3_000
|
||||
|
||||
function makeMeta(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -116,11 +121,14 @@ function makeRuntime(
|
||||
): {
|
||||
runtime: OrcaRuntimeService
|
||||
list: () => Promise<unknown>
|
||||
store: ReturnType<typeof makeStore>
|
||||
} {
|
||||
const runtime = new OrcaRuntimeService(makeStore(options) as never)
|
||||
const store = makeStore(options)
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
return {
|
||||
runtime,
|
||||
list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees()
|
||||
list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees(),
|
||||
store
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +136,11 @@ function scanCount(): number {
|
||||
return listWorktreesStrictMock.mock.calls.length
|
||||
}
|
||||
|
||||
/** Scanned rows carry the mocked tips; the persisted-row fallback publishes empty ones. */
|
||||
function heads(worktrees: unknown): string[] {
|
||||
return (worktrees as { git: { head: string } }[]).map((worktree) => worktree.git.head).sort()
|
||||
}
|
||||
|
||||
/** Let every already-settled promise chain run without advancing the clock, so a stall stays a stall. */
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
for (let tick = 0; tick < 200; tick += 1) {
|
||||
@@ -455,6 +468,80 @@ describe('worktree scan admin-fingerprint gate', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds the awaited probe by the caller per-repo budget', () => {
|
||||
// The wait runs inside that budget, so outlasting it can only strand the caller on persisted rows.
|
||||
expect(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS).toBeLessThan(
|
||||
RESOLVED_WORKTREE_REPO_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('still reuses the scan when a slow probe answers inside its deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime()
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
const releaseSlowProbe = stallProbeOnce()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
const second = list()
|
||||
|
||||
// Deliberately an absolute figure, not one derived from the deadline: this is the lower end of
|
||||
// the band, and a self-referential advance would still pass however far the deadline is cut.
|
||||
await vi.advanceTimersByTimeAsync(SLOW_BUT_HEALTHY_PROBE_MS)
|
||||
releaseSlowProbe('fp-1')
|
||||
await second
|
||||
expect(scanCount()).toBe(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('still returns scanned rows within the per-repo budget when the probe stalls', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime()
|
||||
await list()
|
||||
|
||||
stallProbeOnce()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
const second = list()
|
||||
const secondSettled = trackSettled(second)
|
||||
|
||||
// The probe's own deadline has to end the wait; if the caller's fallback gets there first the
|
||||
// repo answers with persisted rows on every TTL expiry.
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS)
|
||||
await drainMicrotasks()
|
||||
expect(secondSettled()).toBe(true)
|
||||
expect(scanCount()).toBe(2)
|
||||
expect(heads(await second)).toEqual(['abc', 'def'])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not publish an already-expired snapshot after a slow compute', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list, store } = makeRuntime()
|
||||
await list()
|
||||
|
||||
stallProbeOnce()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
const second = list()
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS)
|
||||
await second
|
||||
|
||||
// The probe deadline outlasts the snapshot TTL, so a start-stamped entry is born expired and
|
||||
// the next poll repeats the whole wait instead of reading the answer that just landed.
|
||||
const getRepos = vi.spyOn(store, 'getRepos')
|
||||
await list()
|
||||
expect(getRepos).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shares one probe and one scan across concurrent callers', async () => {
|
||||
const { list } = makeRuntime()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user