mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-26 22:30:50 +00:00
* fix(cursor): exclusive agent spawn lease for list-models vs ACP (#1520) Add a proper-lockfile spawn lease beside agent-acp-active so model probes and ACP transport acquire mutual exclusion atomically before spawning agent children, closing the post-#1518 check-then-act overlap window. Fixes #1520 Co-authored-by: Cursor <cursoragent@cursor.com> * chore(web): fix markdown-a test HappyChatContext mock for typecheck Adds showSessionSummaryInChat to chatContext() so CI typecheck passes on the PR branch (pre-existing main breakage unrelated to #1520). Co-authored-by: Cursor <cursoragent@cursor.com> * Revert "chore(web): fix markdown-a test HappyChatContext mock for typecheck" This reverts commit 4973f321a31fda772e8990ea6cda20517ca906aa. * fix(cursor): scope spawn lease to agent spawn window only (#1520) Hold agent-cli.spawn only around spawn('agent') in AcpStdioTransport, not for the full ACP session. Restores N concurrent cursor sessions per host; list-models probe lease unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): tighten spawn lease lifecycle for babysit (#1520) Acquire spawn lease before ACP marker publish; unregister on spawn failure. Hold list-models probe lease until child exit on timeout. Add missing showSessionSummaryInChat to markdown-a test mock (unblocks CI typecheck). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): re-check ACP marker after spawn lease acquire (#1520) Close check-then-act window where ACP could publish its marker between the inactive guard read and list-models spawn. Add regression test. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cursor): fix ACP-after-acquire mock call order (#1520) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): async spawn lease + force-kill probe timeout (#1520) Add acquireAgentCliSpawnLease (setTimeout yields) for ACP create path; AcpStdioTransport.create() async factory. Probe timeout uses killProcessByChildProcess(force) while holding lease until child exit. Addresses Bugbot Majors: session-lifetime mutex (fe07b708d), probe lease release, post-acquire re-check (137baa779), sync loop starvation, timeout escalation. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(acp): coalesce concurrent initialize() transport spawns (#1520) Await shared bootstrapTransport promise so overlapping initialize calls do not spawn duplicate ACP children while create() is in flight. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
5.0 KiB
TypeScript
171 lines
5.0 KiB
TypeScript
/**
|
|
* Cross-process exclusive lease for Cursor `agent` child processes.
|
|
* ACP transport and `agent --list-models` probes must not overlap — Cursor
|
|
* SIGTERMs the other child (exit 143). Uses proper-lockfile beside the
|
|
* agent-acp-active marker dir so acquisition is atomic (no check-then-act).
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import lockfile from 'proper-lockfile'
|
|
|
|
const SPAWN_LOCK_STALE_MS = 120_000
|
|
const SPAWN_LOCK_UPDATE_MS = 30_000
|
|
const SPAWN_LOCK_RETRY_INTERVAL_MS = 100
|
|
const SPAWN_LOCK_MAX_ATTEMPTS = 300
|
|
|
|
/** Cross-process lease release fn when this process holds the spawn lease. */
|
|
let leaseRelease: (() => void) | null = null
|
|
/** Nested ACP registerActiveAcpTransport calls sharing one lease. */
|
|
let acpRegisterLeaseDepth = 0
|
|
|
|
function sleepMsSync(ms: number): void {
|
|
const bun = (globalThis as { Bun?: { sleepSync?: (duration: number) => void } }).Bun
|
|
if (bun?.sleepSync) {
|
|
bun.sleepSync(ms)
|
|
return
|
|
}
|
|
const end = Date.now() + ms
|
|
while (Date.now() < end) {
|
|
// Node vitest fallback when Bun.sleepSync is unavailable.
|
|
}
|
|
}
|
|
|
|
function spawnLockfilePath(lockTarget: string): string {
|
|
return `${lockTarget}.hapi.lock`
|
|
}
|
|
|
|
function lockOptions(lockTarget: string): {
|
|
realpath: boolean
|
|
lockfilePath: string
|
|
stale: number
|
|
update: number
|
|
retries: number
|
|
} {
|
|
return {
|
|
realpath: false,
|
|
lockfilePath: spawnLockfilePath(lockTarget),
|
|
stale: SPAWN_LOCK_STALE_MS,
|
|
update: SPAWN_LOCK_UPDATE_MS,
|
|
retries: 0,
|
|
}
|
|
}
|
|
|
|
/** Lease anchor file colocated with the agent-acp-active marker directory. */
|
|
export function getAgentCliSpawnLockTarget(hapiHome: string): string {
|
|
const locksDir = join(hapiHome, 'locks')
|
|
mkdirSync(locksDir, { recursive: true })
|
|
const target = join(locksDir, 'agent-cli.spawn')
|
|
if (!existsSync(target)) {
|
|
writeFileSync(target, '', { flag: 'a' })
|
|
}
|
|
return target
|
|
}
|
|
|
|
function claimSpawnLeaseSync(hapiHome: string): boolean {
|
|
if (leaseRelease !== null) {
|
|
return false
|
|
}
|
|
const lockTarget = getAgentCliSpawnLockTarget(hapiHome)
|
|
try {
|
|
leaseRelease = lockfile.lockSync(lockTarget, lockOptions(lockTarget))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Non-blocking exclusive lease for model-list probes. Returns false when
|
|
* another holder (ACP or probe) already owns the spawn lease.
|
|
*/
|
|
export function tryAcquireAgentCliSpawnLeaseSync(hapiHome: string): boolean {
|
|
if (leaseRelease !== null || acpRegisterLeaseDepth > 0) {
|
|
return false
|
|
}
|
|
return claimSpawnLeaseSync(hapiHome)
|
|
}
|
|
|
|
function sleepMs(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
/** Blocking exclusive lease for ACP transport startup (sync — tests only). */
|
|
export function acquireAgentCliSpawnLeaseSync(hapiHome: string): void {
|
|
if (acpRegisterLeaseDepth > 0) {
|
|
acpRegisterLeaseDepth += 1
|
|
return
|
|
}
|
|
|
|
for (let attempt = 0; attempt < SPAWN_LOCK_MAX_ATTEMPTS; attempt++) {
|
|
if (claimSpawnLeaseSync(hapiHome)) {
|
|
acpRegisterLeaseDepth = 1
|
|
return
|
|
}
|
|
sleepMsSync(SPAWN_LOCK_RETRY_INTERVAL_MS)
|
|
}
|
|
|
|
throw new Error('agent CLI spawn lease held by another process')
|
|
}
|
|
|
|
/** Blocking exclusive lease for ACP transport startup (yields event loop between retries). */
|
|
export async function acquireAgentCliSpawnLease(hapiHome: string): Promise<void> {
|
|
if (acpRegisterLeaseDepth > 0) {
|
|
acpRegisterLeaseDepth += 1
|
|
return
|
|
}
|
|
|
|
for (let attempt = 0; attempt < SPAWN_LOCK_MAX_ATTEMPTS; attempt++) {
|
|
if (claimSpawnLeaseSync(hapiHome)) {
|
|
acpRegisterLeaseDepth = 1
|
|
return
|
|
}
|
|
await sleepMs(SPAWN_LOCK_RETRY_INTERVAL_MS)
|
|
}
|
|
|
|
throw new Error('agent CLI spawn lease held by another process')
|
|
}
|
|
|
|
/** Release after a list-models probe child exits. */
|
|
export function releaseAgentCliSpawnLeaseSync(): void {
|
|
if (acpRegisterLeaseDepth > 0 || leaseRelease === null) {
|
|
return
|
|
}
|
|
leaseRelease()
|
|
leaseRelease = null
|
|
}
|
|
|
|
/** Release after the last ACP transport unregisters in this process. */
|
|
export function releaseAgentCliSpawnLeaseFromAcpRegisterSync(): void {
|
|
if (acpRegisterLeaseDepth <= 0) {
|
|
return
|
|
}
|
|
acpRegisterLeaseDepth -= 1
|
|
if (acpRegisterLeaseDepth > 0 || leaseRelease === null) {
|
|
return
|
|
}
|
|
leaseRelease()
|
|
leaseRelease = null
|
|
}
|
|
|
|
/** @internal test-only */
|
|
export function _resetAgentCliSpawnLeaseForTests(hapiHome?: string): void {
|
|
acpRegisterLeaseDepth = 0
|
|
if (leaseRelease) {
|
|
leaseRelease()
|
|
leaseRelease = null
|
|
}
|
|
if (!hapiHome) {
|
|
return
|
|
}
|
|
const lockTarget = getAgentCliSpawnLockTarget(hapiHome)
|
|
try {
|
|
lockfile.unlockSync(lockTarget, {
|
|
realpath: false,
|
|
lockfilePath: spawnLockfilePath(lockTarget),
|
|
})
|
|
} catch {
|
|
// Best effort — lock may not exist.
|
|
}
|
|
}
|