Repository navigation
Harden broker startup and persona discovery #164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| import { existsSync, readFileSync } from 'fs' | ||
| import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' | ||
| import { rm } from 'fs/promises' | ||
| import { randomUUID } from 'node:crypto' | ||
| import { homedir } from 'node:os' | ||
| import { delimiter, basename, isAbsolute, join } from 'path' | ||
| import { execFileSync } from 'child_process' | ||
| import { execFile, execFileSync } from 'child_process' | ||
| import { app, BrowserWindow } from 'electron' | ||
| import { | ||
| HarnessDriverClient as AgentRelayClient, | ||
|
|
@@ -306,6 +306,154 @@ function resolveBundledBrokerBinary(): string { | |
| return unpackIfPackaged(brokerBinary) | ||
| } | ||
|
|
||
| interface BrokerInitCliFlags { | ||
| supportsInstanceName: boolean | ||
| supportsName: boolean | ||
| supportsWorkspaceKey: boolean | ||
| } | ||
|
|
||
| export function parseBrokerInitCliFlags(helpText: string): BrokerInitCliFlags { | ||
| return { | ||
| supportsInstanceName: /(?:^|\s)--instance-name(?:\s|[<=[,]|$)/.test(helpText), | ||
| supportsName: /(?:^|\s)--name(?:\s|[<=[,]|$)/.test(helpText), | ||
| supportsWorkspaceKey: /(?:^|\s)--workspace-key(?:\s|[<=[,]|$)/.test(helpText) | ||
| } | ||
| } | ||
|
|
||
| function inspectBrokerInitCliFlags(binaryPath: string): Promise<BrokerInitCliFlags> { | ||
| return new Promise((resolve) => { | ||
| execFile(binaryPath, ['init', '--help'], { | ||
| encoding: 'utf8', | ||
| timeout: 2_000, | ||
| windowsHide: true | ||
| }, (err, stdout) => { | ||
| if (err) { | ||
| console.warn(`[broker] Failed to inspect broker init CLI for ${binaryPath}:`, err) | ||
| resolve({ | ||
| supportsInstanceName: true, | ||
| supportsName: true, | ||
| supportsWorkspaceKey: true | ||
| }) | ||
| return | ||
| } | ||
| resolve(parseBrokerInitCliFlags(stdout)) | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| function brokerBinaryCompatShimSource(): string { | ||
| return `#!/usr/bin/env node | ||
| const { spawn } = require('node:child_process') | ||
|
|
||
| const realBinary = process.env.PEAR_AGENT_RELAY_BROKER_BINARY | ||
| if (!realBinary) { | ||
| console.error('[pear-broker-compat] PEAR_AGENT_RELAY_BROKER_BINARY is required') | ||
| process.exit(127) | ||
| } | ||
|
|
||
| const supportsInstanceName = process.env.PEAR_AGENT_RELAY_BROKER_SUPPORTS_INSTANCE_NAME === '1' | ||
| const supportsWorkspaceKey = process.env.PEAR_AGENT_RELAY_BROKER_SUPPORTS_WORKSPACE_KEY === '1' | ||
| const inputArgs = process.argv.slice(2) | ||
| const outputArgs = [] | ||
|
|
||
| for (let index = 0; index < inputArgs.length; index += 1) { | ||
| const arg = inputArgs[index] | ||
| if (!supportsInstanceName && arg === '--instance-name') { | ||
| outputArgs.push('--name') | ||
| if (index + 1 < inputArgs.length) outputArgs.push(inputArgs[++index]) | ||
| continue | ||
| } | ||
| if (!supportsInstanceName && arg.startsWith('--instance-name=')) { | ||
| outputArgs.push('--name=' + arg.slice('--instance-name='.length)) | ||
| continue | ||
| } | ||
| if (!supportsWorkspaceKey && arg === '--workspace-key') { | ||
| index += 1 | ||
| continue | ||
| } | ||
| if (!supportsWorkspaceKey && arg.startsWith('--workspace-key=')) { | ||
| continue | ||
| } | ||
| outputArgs.push(arg) | ||
| } | ||
|
|
||
| const child = spawn(realBinary, outputArgs, { | ||
| cwd: process.cwd(), | ||
| env: process.env, | ||
| stdio: 'inherit' | ||
| }) | ||
|
|
||
| child.on('error', (error) => { | ||
| console.error('[pear-broker-compat] failed to launch broker:', error instanceof Error ? error.message : String(error)) | ||
| process.exit(127) | ||
| }) | ||
|
|
||
| child.on('exit', (code, signal) => { | ||
| if (signal) { | ||
| try { | ||
| process.kill(process.pid, signal) | ||
| } catch { | ||
| process.exit(1) | ||
| } | ||
| return | ||
| } | ||
| process.exit(code ?? 0) | ||
| }) | ||
|
Comment on lines
+391
to
+401
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On Windows, calling child.on('exit', (code, signal) => {
if (signal) {
try {
process.kill(process.pid, signal)
} catch {
process.exit(1)
}
return
}
process.exit(code ?? 0)
}) |
||
| ` | ||
| } | ||
|
|
||
| function ensureBrokerBinaryCompatShim(): string { | ||
| const shimDir = join(app.getPath('userData'), 'broker-compat') | ||
| const shimPath = join(shimDir, process.platform === 'win32' ? 'agent-relay-broker-compat.cmd' : 'agent-relay-broker-compat') | ||
| const source = process.platform === 'win32' | ||
| ? `@echo off\r\nnode "%~dp0agent-relay-broker-compat.js" %*\r\n` | ||
| : brokerBinaryCompatShimSource() | ||
|
Comment on lines
+341
to
+403
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Compat shim requires a system Both POSIX ( Also applies to: 410-417 🤖 Prompt for AI Agents |
||
|
|
||
| mkdirSync(shimDir, { recursive: true }) | ||
| if (!existsSync(shimPath) || readFileSync(shimPath, 'utf8') !== source) { | ||
| writeFileSync(shimPath, source) | ||
| chmodSync(shimPath, 0o755) | ||
| } | ||
| if (process.platform === 'win32') { | ||
| const jsPath = join(shimDir, 'agent-relay-broker-compat.js') | ||
| const jsSource = brokerBinaryCompatShimSource() | ||
| if (!existsSync(jsPath) || readFileSync(jsPath, 'utf8') !== jsSource) { | ||
| writeFileSync(jsPath, jsSource) | ||
| } | ||
| } | ||
| return shimPath | ||
| } | ||
|
|
||
| async function resolveHarnessBrokerBinary(workspaceKey?: string): Promise<{ binaryPath: string; env: NodeJS.ProcessEnv }> { | ||
| const binaryPath = resolveBundledBrokerBinary() | ||
| const flags = await inspectBrokerInitCliFlags(binaryPath) | ||
|
|
||
| if (workspaceKey && !flags.supportsWorkspaceKey) { | ||
| throw new Error( | ||
| `Broker binary does not support --workspace-key: ${binaryPath}. Rebuild or update agent-relay-broker, or unset AGENT_RELAY_WORKSPACE_KEY.` | ||
| ) | ||
| } | ||
|
|
||
| if (flags.supportsInstanceName) { | ||
| return { binaryPath, env: {} } | ||
| } | ||
|
|
||
| if (!flags.supportsName) { | ||
| throw new Error(`Broker binary supports neither --instance-name nor --name: ${binaryPath}`) | ||
| } | ||
|
|
||
| const shimPath = ensureBrokerBinaryCompatShim() | ||
| console.warn(`[broker] Broker binary uses legacy --name flag; launching through compatibility shim: ${binaryPath}`) | ||
| return { | ||
| binaryPath: shimPath, | ||
| env: { | ||
| PEAR_AGENT_RELAY_BROKER_BINARY: binaryPath, | ||
| PEAR_AGENT_RELAY_BROKER_SUPPORTS_INSTANCE_NAME: flags.supportsInstanceName ? '1' : '0', | ||
| PEAR_AGENT_RELAY_BROKER_SUPPORTS_WORKSPACE_KEY: flags.supportsWorkspaceKey ? '1' : '0' | ||
| } | ||
| } | ||
| } | ||
|
|
||
| type TerminalAttachMode = 'view' | 'drive' | 'passthrough' | ||
|
|
||
| export interface CloudAgentSandboxHandle { | ||
|
|
@@ -1396,15 +1544,17 @@ export class BrokerManager { | |
|
|
||
| // Phase 1 of #125: the local broker stays the workspace creator, so the | ||
| // key is only threaded when explicitly pinned via env. | ||
| const brokerBinary = await resolveHarnessBrokerBinary(explicitWorkspaceKey) | ||
| const opts: AgentRelaySpawnOptions = { | ||
| cwd, | ||
| brokerName: name, | ||
| channels: nextChannels, | ||
| binaryArgs: { persist: true }, | ||
| binaryPath: resolveBundledBrokerBinary(), | ||
| binaryPath: brokerBinary.binaryPath, | ||
| ...(explicitWorkspaceKey ? { workspaceKey: explicitWorkspaceKey } : {}), | ||
| env: { | ||
| PATH: augmentedPath(), | ||
| ...brokerBinary.env, | ||
| ...(agentRelayMcpCommand ? { AGENT_RELAY_MCP_COMMAND: agentRelayMcpCommand } : {}) | ||
| }, | ||
| onStderr: (line: string) => { | ||
|
|
@@ -2595,8 +2745,24 @@ export class BrokerManager { | |
| throw new Error(`Unable to allocate an agent name for ${relayAwareInput.name}`) | ||
| } | ||
|
|
||
| async listPersonas(projectId: string): Promise<WorkforcePersona[]> { | ||
| const session = this.getSessionForProject(projectId) | ||
| async listPersonas(projectId: string, cwd?: string): Promise<WorkforcePersona[]> { | ||
| const personaCwd = cwd?.trim() | ||
| if (personaCwd) { | ||
| try { | ||
| return listWorkforcePersonas(personaCwd) | ||
| } catch (err) { | ||
| console.warn(`[broker] Failed to list workforce personas for project ${projectId} at ${personaCwd}:`, err) | ||
| return [] | ||
| } | ||
| } | ||
|
|
||
| let session: BrokerSession | ||
| try { | ||
| ;[session] = await this.getOrAwaitSessionsForProject(projectId) | ||
| } catch (err) { | ||
| if (isWorkspaceNotStartedError(err)) return [] | ||
| throw err | ||
| } | ||
|
|
||
| try { | ||
| return listWorkforcePersonas(session.cwd) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -265,7 +265,19 @@ export function registerIpcHandlers(): void { | |
| return toBrokerSpawnAgentResult(result) | ||
| }) | ||
|
|
||
| ipcMain.handle('broker:list-personas', async (_, projectId: string) => { | ||
| ipcMain.handle('broker:list-personas', async (_, projectId: string, cwd?: string) => { | ||
| const personaCwd = cwd?.trim() | ||
| if (personaCwd) { | ||
| const resolved = resolve(personaCwd) | ||
| const pathProjectId = getProjectIdForPath(resolved) | ||
| if (!pathProjectId || pathProjectId !== projectId.trim()) { | ||
| throw new Error(`Path is outside project roots for project ${projectId}: ${resolved}`) | ||
| } | ||
| if (!isDirectory(resolved)) { | ||
| return [] | ||
| } | ||
| return brokerManager.listPersonas(projectId, resolved) | ||
| } | ||
| return brokerManager.listPersonas(projectId) | ||
| }) | ||
|
Comment on lines
+268
to
282
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The ipcMain.handle('broker:list-personas', async (_, projectId: string, cwd?: string) => {
const personaCwd = cwd?.trim()
if (personaCwd) {
assertPathWithinProjects(personaCwd)
if (!isDirectory(personaCwd)) {
return []
}
}
return brokerManager.listPersonas(projectId, personaCwd)
}) |
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Add startup regression coverage for the compat-shim execution path.
These tests validate flag parsing, but they don’t verify
BrokerManager.startactually switches to shim binary and forwardsPEAR_AGENT_RELAY_BROKER_*env for legacy brokers (including replay/duplicate start behavior).Based on learnings and coding guidelines: add regression tests when touching broker start, and include duplicate/replay cases, not just happy path.
🤖 Prompt for AI Agents
Sources: Coding guidelines, Learnings