Skip to content

Commit 6e66172

Browse files
authored
Merge pull request #136 from AgentWorkforce/fix/persona-spawn-clone-payload
Return clone-safe persona spawn payloads
2 parents bb46f34 + a0eb70e commit 6e66172

2 files changed

Lines changed: 162 additions & 23 deletions

File tree

src/main/broker.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,29 @@ async function attachCloud(manager: BrokerManager, agents: string[] = []): Promi
220220
return lastConstructed()
221221
}
222222

223+
async function writeAgentWorkforceFixture(projectDir: string): Promise<void> {
224+
const binDir = join(projectDir, 'node_modules', '.bin')
225+
const posixBin = join(binDir, 'agentworkforce')
226+
const winBin = join(binDir, 'agentworkforce.cmd')
227+
const jsBin = join(binDir, 'agentworkforce.js')
228+
const script = [
229+
'const command = process.argv[2]',
230+
"if (command === 'show') {",
231+
" console.log(JSON.stringify({ spec: { id: 'autonomous-actor', harness: 'claude' } }))",
232+
'} else {',
233+
" console.log(JSON.stringify({ personas: [{ persona: 'autonomous-actor', harness: 'claude' }] }))",
234+
'}'
235+
].join('\n')
236+
237+
await mkdir(binDir, { recursive: true })
238+
await writeFile(jsBin, script)
239+
await writeFile(posixBin, `#!/usr/bin/env node\n${script}\n`)
240+
await writeFile(winBin, `@echo off\r\nnode "%~dp0agentworkforce.js" %*\r\n`)
241+
await chmod(jsBin, 0o755)
242+
await chmod(posixBin, 0o755)
243+
await chmod(winBin, 0o755)
244+
}
245+
223246
describe('resolveAgentRelayMcpCommand', () => {
224247
let tempDir: string | null = null
225248
let extraTempDir: string | null = null
@@ -328,6 +351,8 @@ describe('resolveAgentRelayMcpCommand', () => {
328351
})
329352

330353
describe('BrokerManager local + cloud coexistence', () => {
354+
let personaTempDir: string | null = null
355+
331356
beforeEach(() => {
332357
mock.state.spawnedClients.length = 0
333358
mock.state.constructedClients.length = 0
@@ -340,6 +365,11 @@ describe('BrokerManager local + cloud coexistence', () => {
340365
mock.HarnessDriverClient.connect.mockClear()
341366
})
342367

368+
afterEach(async () => {
369+
if (personaTempDir) await rm(personaTempDir, { recursive: true, force: true })
370+
personaTempDir = null
371+
})
372+
343373
it('keeps the local session alive when a cloud sandbox attaches', async () => {
344374
const manager = new BrokerManager()
345375
const local = await startLocal(manager, ['local-agent'])
@@ -565,6 +595,78 @@ describe('BrokerManager local + cloud coexistence', () => {
565595
await manager.shutdown()
566596
})
567597

598+
it('returns a clone-safe payload when spawning a workforce persona', async () => {
599+
personaTempDir = await mkdtemp(join(tmpdir(), 'pear-persona-spawn-'))
600+
await writeAgentWorkforceFixture(personaTempDir)
601+
602+
const manager = new BrokerManager()
603+
mock.state.nextLocalAgents = []
604+
await manager.start(PROJECT_ID, personaTempDir, 'pear-project-1', undefined as never, [])
605+
const local = lastSpawned()
606+
local.spawnPty.mockImplementationOnce(async (input: { name: string }) => {
607+
local.agentNames.push(input.name)
608+
return {
609+
name: input.name,
610+
runtime: 'pty',
611+
cli: 'agentworkforce',
612+
nonCloneable: () => undefined
613+
}
614+
})
615+
616+
const result = await manager.spawnPersona(PROJECT_ID, 'autonomous-actor')
617+
618+
expect(result).toEqual({
619+
name: 'autonomous-actor',
620+
runtime: 'pty',
621+
cli: 'claude'
622+
})
623+
expect(() => structuredClone(result)).not.toThrow()
624+
625+
await manager.shutdown()
626+
})
627+
628+
it('coalesces concurrent duplicate workforce persona spawn requests', async () => {
629+
personaTempDir = await mkdtemp(join(tmpdir(), 'pear-persona-spawn-'))
630+
await writeAgentWorkforceFixture(personaTempDir)
631+
632+
const manager = new BrokerManager()
633+
mock.state.nextLocalAgents = []
634+
await manager.start(PROJECT_ID, personaTempDir, 'pear-project-1', undefined as never, [])
635+
const local = lastSpawned()
636+
let releaseSpawn!: () => void
637+
local.spawnPty.mockImplementationOnce(async (input: { name: string }) => {
638+
await new Promise<void>((resolve) => {
639+
releaseSpawn = resolve
640+
})
641+
local.agentNames.push(input.name)
642+
return {
643+
name: input.name,
644+
runtime: 'pty',
645+
cli: 'agentworkforce',
646+
nonCloneable: () => undefined
647+
}
648+
})
649+
650+
const first = manager.spawnPersona(PROJECT_ID, 'autonomous-actor')
651+
const second = manager.spawnPersona(PROJECT_ID, 'autonomous-actor')
652+
await Promise.resolve()
653+
await Promise.resolve()
654+
655+
expect(local.spawnPty).toHaveBeenCalledTimes(1)
656+
releaseSpawn()
657+
const results = await Promise.all([first, second])
658+
659+
expect(results).toEqual([
660+
{ name: 'autonomous-actor', runtime: 'pty', cli: 'claude' },
661+
{ name: 'autonomous-actor', runtime: 'pty', cli: 'claude' }
662+
])
663+
expect(() => structuredClone(results[0])).not.toThrow()
664+
expect(() => structuredClone(results[1])).not.toThrow()
665+
expect(local.agentNames).toEqual(['autonomous-actor'])
666+
667+
await manager.shutdown()
668+
})
669+
568670
it('spawning with broker: cloud fails clearly when no sandbox is attached', async () => {
569671
const manager = new BrokerManager()
570672
await startLocal(manager)

src/main/broker.ts

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -389,10 +389,41 @@ function spawnRequestKey(
389389
})
390390
}
391391

392+
function personaSpawnRequestKey(projectId: string, personaId: string): string {
393+
return JSON.stringify({
394+
projectId,
395+
personaId
396+
})
397+
}
398+
392399
function isRecord(value: unknown): value is Record<string, unknown> {
393400
return typeof value === 'object' && value !== null
394401
}
395402

403+
type BrokerSpawnResult = {
404+
name: string
405+
runtime: string
406+
cli?: string
407+
}
408+
409+
function normalizeSpawnPtyResult(value: unknown, fallbackName: string, cli?: string): BrokerSpawnResult {
410+
const record = isRecord(value) ? value : {}
411+
const name = typeof record.name === 'string' && record.name.trim()
412+
? record.name.trim()
413+
: fallbackName
414+
const runtime = typeof record.runtime === 'string' && record.runtime.trim()
415+
? record.runtime.trim()
416+
: 'pty'
417+
const result: BrokerSpawnResult = { name, runtime }
418+
const resolvedCli = typeof cli === 'string' && cli.trim()
419+
? cli.trim()
420+
: typeof record.cli === 'string' && record.cli.trim()
421+
? record.cli.trim()
422+
: undefined
423+
if (resolvedCli) result.cli = resolvedCli
424+
return result
425+
}
426+
396427
function brokerEventString(event: BrokerEvent, key: string): string | undefined {
397428
const value = (event as unknown as Record<string, unknown>)[key]
398429
return typeof value === 'string' ? value : undefined
@@ -1176,7 +1207,7 @@ export class BrokerManager {
11761207
private sessions = new Map<string, BrokerSession>()
11771208
private startPromises = new Map<string, Promise<boolean | void>>()
11781209
private revivePromises = new Map<string, Promise<boolean>>()
1179-
private inFlightSpawnRequests = new Map<string, Promise<{ name: string; runtime: string }>>()
1210+
private inFlightSpawnRequests = new Map<string, Promise<BrokerSpawnResult>>()
11801211
// Which broker sessions (by session key) an agent name is registered on.
11811212
// Both a project's local and cloud brokers join the same relay workspace,
11821213
// so agent names are project-unique in practice — the set tracks which
@@ -2306,12 +2337,12 @@ export class BrokerManager {
23062337
projectId: string,
23072338
spawnInput: SpawnPtyInput & { broker?: 'local' | 'cloud' },
23082339
options: { parentAgentName?: string } = {}
2309-
): Promise<{ name: string; runtime: string }> {
2340+
): Promise<BrokerSpawnResult> {
23102341
const requestKey = spawnRequestKey(projectId, spawnInput, options)
23112342
const inFlight = this.inFlightSpawnRequests.get(requestKey)
23122343
if (inFlight) return inFlight
23132344

2314-
let promise!: Promise<{ name: string; runtime: string }>
2345+
let promise!: Promise<BrokerSpawnResult>
23152346
promise = this.spawnAgentOnce(projectId, spawnInput, options).finally(() => {
23162347
if (this.inFlightSpawnRequests.get(requestKey) === promise) {
23172348
this.inFlightSpawnRequests.delete(requestKey)
@@ -2325,7 +2356,7 @@ export class BrokerManager {
23252356
projectId: string,
23262357
spawnInput: SpawnPtyInput & { broker?: 'local' | 'cloud' },
23272358
options: { parentAgentName?: string } = {}
2328-
): Promise<{ name: string; runtime: string }> {
2359+
): Promise<BrokerSpawnResult> {
23292360
// `broker` selects which of the project's sessions the agent spawns on.
23302361
// Default: local-first via getSessionForProject (cloud only when no local
23312362
// broker is running, preserving the cloud-only flow).
@@ -2377,7 +2408,8 @@ export class BrokerManager {
23772408
)
23782409
}
23792410
const spawned = await session.client.spawnPty(nextInput)
2380-
const spawnedName = spawned.name || nextInput.name
2411+
const safeSpawned = normalizeSpawnPtyResult(spawned, nextInput.name)
2412+
const spawnedName = safeSpawned.name
23812413
this.rememberAgentSession(spawnedName, sessionKeyFor(session))
23822414
const burnInput = { ...nextInput, name: spawnedName }
23832415
const lineage = session.pearLineage.get(spawnedName)
@@ -2393,12 +2425,7 @@ export class BrokerManager {
23932425
).catch((err) => {
23942426
console.warn('[burn-spawn-hook] post-spawn burn stamp failed:', err)
23952427
})
2396-
return {
2397-
name: spawnedName,
2398-
runtime: typeof spawned.runtime === 'string' && spawned.runtime.trim()
2399-
? spawned.runtime
2400-
: 'pty'
2401-
}
2428+
return safeSpawned
24022429
} catch (err) {
24032430
if (!isAgentNameConflict(err)) {
24042431
throw buildSpawnFailureError(err, nextInput, session.cloudSandboxId ? 'cloud' : 'local')
@@ -2425,13 +2452,29 @@ export class BrokerManager {
24252452
}
24262453
}
24272454

2428-
async spawnPersona(projectId: string, personaId: string): Promise<{ name: string; runtime: string; cli?: string }> {
2429-
const session = this.getSessionForProject(projectId)
2455+
async spawnPersona(projectId: string, personaId: string): Promise<BrokerSpawnResult> {
24302456
const trimmedPersonaId = personaId.trim()
24312457
if (!trimmedPersonaId) {
24322458
throw new Error('Persona id is required')
24332459
}
24342460

2461+
const requestKey = personaSpawnRequestKey(projectId, trimmedPersonaId)
2462+
const inFlight = this.inFlightSpawnRequests.get(requestKey)
2463+
if (inFlight) return inFlight
2464+
2465+
let promise!: Promise<BrokerSpawnResult>
2466+
promise = this.spawnPersonaOnce(projectId, trimmedPersonaId).finally(() => {
2467+
if (this.inFlightSpawnRequests.get(requestKey) === promise) {
2468+
this.inFlightSpawnRequests.delete(requestKey)
2469+
}
2470+
})
2471+
this.inFlightSpawnRequests.set(requestKey, promise)
2472+
return promise
2473+
}
2474+
2475+
private async spawnPersonaOnce(projectId: string, trimmedPersonaId: string): Promise<BrokerSpawnResult> {
2476+
const session = this.getSessionForProject(projectId)
2477+
24352478
const command = resolveAgentWorkforceCommand(session.cwd)
24362479
const persona = findWorkforcePersona(session.cwd, trimmedPersonaId, command)
24372480

@@ -2478,7 +2521,7 @@ export class BrokerManager {
24782521
command: { cli: string; args: string[] }
24792522
resolvedHarness: string
24802523
}
2481-
): Promise<{ name: string; runtime: string; cli?: string }> {
2524+
): Promise<BrokerSpawnResult> {
24822525
const existingNames = new Set(
24832526
(await session.client.listAgents()).map((agent) => agent.name)
24842527
)
@@ -2495,15 +2538,9 @@ export class BrokerManager {
24952538
for (let attempt = 0; attempt < 20; attempt += 1) {
24962539
try {
24972540
const spawned = await session.client.spawnPty(nextInput)
2498-
const spawnedName = spawned.name || nextInput.name
2499-
this.rememberAgentSession(spawnedName, sessionKeyFor(session))
2500-
return {
2501-
name: spawnedName,
2502-
runtime: typeof spawned.runtime === 'string' && spawned.runtime.trim()
2503-
? spawned.runtime
2504-
: 'pty',
2505-
cli: input.resolvedHarness
2506-
}
2541+
const safeSpawned = normalizeSpawnPtyResult(spawned, nextInput.name, input.resolvedHarness)
2542+
this.rememberAgentSession(safeSpawned.name, sessionKeyFor(session))
2543+
return safeSpawned
25072544
} catch (err) {
25082545
if (!isAgentNameConflict(err)) {
25092546
throw err

0 commit comments

Comments
 (0)