Repository navigation
feat(watch): /fleet hybrid dispatch — 2.9x faster parallel execution #776
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
c0cbf16
3fd1f43
6d814d2
7d57659
5f30058
27113c4
cb413bf
3be0464
e07e8f9
5c7ec10
ae866a4
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 |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@bradygaster/squad-cli": minor | ||
| --- | ||
|
|
||
| Add /fleet hybrid dispatch mode for squad watch --execute (#775) — enables parallel issue analysis via Copilot CLI /fleet, 2.9x faster than sequential dispatch for read-heavy work |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "@bradygaster/squad-sdk": minor | ||
| --- | ||
|
|
||
| Add `.squad/.scratch/` directory for organized temp file management (#790) | ||
|
|
||
| - `scratchDir()` — resolve and optionally create the scratch directory | ||
| - `scratchFile()` — create named temp files inside `.scratch/` | ||
| - Path traversal protection: prefix/ext sanitized to prevent `../` attacks | ||
| - Monotonic counter ensures unique filenames even within the same millisecond | ||
| - Agents and CLI should use these utilities instead of writing temp files to repo root |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ | |
| */ | ||
|
|
||
| import { execFile, type ChildProcess } from 'node:child_process'; | ||
| import { existsSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; | ||
| import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities'; | ||
|
|
||
|
|
@@ -15,18 +17,32 @@ export interface ExecutableWorkItem { | |
| assignees: Array<{ login: string }>; | ||
| } | ||
|
|
||
| /** Labels that block autonomous execution. */ | ||
| const BLOCKED_LABELS: ReadonlySet<string> = new Set([ | ||
| 'status:blocked', | ||
| 'status:waiting-external', | ||
| 'status:postponed', | ||
| 'status:scheduled', | ||
| 'status:needs-action', | ||
| 'status:needs-decision', | ||
| 'status:needs-review', | ||
| 'pending-user', | ||
| 'do-not-merge', | ||
| ]); | ||
| /** Check whether an issue carries a squad or squad:* label. */ | ||
| function hasSquadLabel(issue: ExecutableWorkItem): boolean { | ||
| return issue.labels.some(l => l.name === 'squad' || l.name.startsWith('squad:')); | ||
| } | ||
|
|
||
| /** Keywords that indicate read-heavy / analysis work. */ | ||
| const READ_KEYWORDS = [ | ||
| 'research', 'review', 'analyze', 'investigate', 'audit', | ||
| 'check', 'scan', 'assess', 'evaluate', 'fact-check', | ||
| 'document', 'report', | ||
| ]; | ||
|
|
||
| /** Keywords that indicate write-heavy / implementation work. */ | ||
| const WRITE_KEYWORDS = [ | ||
| 'fix', 'implement', 'create', 'build', 'refactor', | ||
| 'add', 'update', 'migrate', 'deploy', 'feature', | ||
| ]; | ||
|
|
||
| /** Classify an issue as read-heavy or write-heavy by title keywords. */ | ||
| export function classifyIssue(title: string): 'read' | 'write' { | ||
| const lower = title.toLowerCase(); | ||
| const isRead = READ_KEYWORDS.some(k => lower.includes(k)); | ||
| const isWrite = WRITE_KEYWORDS.some(k => lower.includes(k)); | ||
| if (isRead && !isWrite) return 'read'; | ||
| return 'write'; // default to write (safer — gets full agent session) | ||
| } | ||
|
Comment on lines
+25
to
+45
|
||
|
|
||
| /** Build agent command for a prompt. */ | ||
| function buildAgentCommand( | ||
|
|
@@ -46,38 +62,92 @@ function buildAgentCommand( | |
| return { cmd: 'gh', args }; | ||
| } | ||
|
|
||
| /** Find issues eligible for autonomous execution. */ | ||
| /** Labels that indicate an issue should not be auto-executed. */ | ||
| const BLOCKING_LABELS = ['status:blocked', 'status:wontfix', 'status:on-hold', 'blocked']; | ||
|
|
||
| /** Check whether an issue has a blocking status label. */ | ||
| function hasBlockingLabel(issue: ExecutableWorkItem): boolean { | ||
| return issue.labels.some(l => BLOCKING_LABELS.includes(l.name)); | ||
| } | ||
|
|
||
| /** Check whether an issue is already assigned to a human. */ | ||
| function isAssigned(issue: ExecutableWorkItem): boolean { | ||
| return issue.assignees.length > 0; | ||
| } | ||
|
|
||
| /** Find issues eligible for autonomous execution. | ||
| * | ||
| * Pre-filters to keep only clearly actionable items: | ||
| * - must have a squad/squad:* label | ||
| * - must not be assigned to a human (agent decides once it reads ralph-instructions.md) | ||
| * - must not carry a blocking status label | ||
| * | ||
| * Matches the PS1 ralph-watch pre-filter design. | ||
| */ | ||
| export function findExecutableIssues( | ||
| roster: Array<{ name: string; label: string; expertise: string[] }>, | ||
| capabilities: MachineCapabilities | null, | ||
| _roster: Array<{ name: string; label: string; expertise: string[] }>, | ||
| _capabilities: MachineCapabilities | null, | ||
| issues: ExecutableWorkItem[], | ||
| ): ExecutableWorkItem[] { | ||
| const memberLabels = new Set(roster.map(m => m.label)); | ||
| return issues.filter(issue => { | ||
| const labels = issue.labels.map(l => l.name); | ||
| if (!labels.some(l => memberLabels.has(l))) return false; | ||
| if (issue.assignees && issue.assignees.length > 0) return false; | ||
| if (labels.some(l => BLOCKED_LABELS.has(l))) return false; | ||
| return true; | ||
| }); | ||
| return issues.filter( | ||
| issue => hasSquadLabel(issue) && !isAssigned(issue) && !hasBlockingLabel(issue), | ||
| ); | ||
| } | ||
|
|
||
| /** Format issue list for the agent prompt. */ | ||
| function formatIssueList(issues: ExecutableWorkItem[]): string { | ||
| return issues.map(i => { | ||
| const labels = i.labels.map(l => l.name).join(', '); | ||
| const assigned = i.assignees?.length | ||
| ? `assigned: ${i.assignees.map(a => a.login).join(',')}` | ||
| : 'unassigned'; | ||
| return `- #${i.number}: ${i.title} [${labels}] ${assigned}`; | ||
| }).join('\n'); | ||
| } | ||
|
|
||
| /** Build the rich agent prompt matching PS1 ralph-watch design. */ | ||
| export function buildAgentPrompt( | ||
| issues: ExecutableWorkItem[], | ||
| teamRoot: string, | ||
| ): string { | ||
| const issueList = formatIssueList(issues); | ||
| const hasInstructions = existsSync(path.join(teamRoot, '.squad', 'ralph-instructions.md')); | ||
|
|
||
| if (hasInstructions) { | ||
| return [ | ||
| 'Ralph, Go! Read .squad/ralph-instructions.md for your full instructions. Follow ALL sections there. MAXIMIZE PARALLELISM — spawn agents for ALL actionable issues simultaneously.', | ||
| '', | ||
| 'Here are the current open squad issues:', | ||
| issueList, | ||
| '', | ||
| 'Task: Read the issues, follow your instructions in .squad/ralph-instructions.md, and work on what\'s actionable.', | ||
| 'WHY: Keep the squad pipeline moving — no idle work.', | ||
| 'Success: Issues get branches, PRs, and progress.', | ||
| 'Escalation: If blocked, comment on the issue and move to next.', | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| // Fallback when ralph-instructions.md does not exist | ||
| return [ | ||
| 'You are Ralph, the autonomous work monitor. Review the open squad issues below and work on every actionable one. Skip issues that are blocked, waiting on external input, or already assigned.', | ||
| '', | ||
| 'Here are the current open squad issues:', | ||
| issueList, | ||
| '', | ||
| 'Task: Triage the list, pick up unblocked/unassigned issues, create branches and PRs.', | ||
| 'WHY: Keep the squad pipeline moving — no idle work.', | ||
| 'Success: Issues get branches, PRs, and progress.', | ||
| 'Escalation: If blocked, comment on the issue and move to next.', | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| /** Spawn agent for a single issue. */ | ||
| async function executeOne( | ||
| issue: ExecutableWorkItem, | ||
| /** Spawn a single agent session for all eligible issues. */ | ||
| async function executeAll( | ||
| issues: ExecutableWorkItem[], | ||
| context: WatchContext, | ||
| timeoutMs: number, | ||
| ): Promise<{ success: boolean; error?: string }> { | ||
| // Claim the issue | ||
| try { | ||
| await context.adapter.addTag(issue.number, 'status:in-progress'); | ||
| } catch { /* best-effort */ } | ||
|
|
||
| try { | ||
| await context.adapter.addComment(issue.number, '🤖 Ralph: starting autonomous work on this issue.'); | ||
| } catch { /* best-effort */ } | ||
|
|
||
| const prompt = `Work on issue #${issue.number}: ${issue.title}. Read the issue body for full details.`; | ||
| const prompt = buildAgentPrompt(issues, context.teamRoot); | ||
| const { cmd, args } = buildAgentCommand(prompt, context); | ||
|
|
||
| return new Promise<{ success: boolean; error?: string }>((resolve) => { | ||
|
|
@@ -115,7 +185,6 @@ export class ExecuteCapability implements WatchCapability { | |
|
|
||
| async execute(context: WatchContext): Promise<CapabilityResult> { | ||
| try { | ||
| const maxConcurrent = (context.config['maxConcurrent'] as number) ?? 1; | ||
| const timeout = ((context.config['timeout'] as number) ?? 30) * 60_000; | ||
|
|
||
| // Fetch open issues with squad label | ||
|
|
@@ -127,31 +196,25 @@ export class ExecuteCapability implements WatchCapability { | |
| assignees: wi.assignedTo ? [{ login: wi.assignedTo }] : [], | ||
| })); | ||
|
|
||
| // Filter by capabilities | ||
| const { filterByCapabilities, loadCapabilities } = await import('@bradygaster/squad-sdk/ralph/capabilities'); | ||
| const capabilities = await loadCapabilities(context.teamRoot); | ||
| const { handled } = filterByCapabilities(issues, capabilities); | ||
| // Minimal filter: must have squad or squad:* label (agent decides the rest) | ||
| const eligible = findExecutableIssues(context.roster, null, issues); | ||
|
|
||
| const executable = findExecutableIssues(context.roster, capabilities, handled); | ||
| const batch = executable.slice(0, maxConcurrent); | ||
|
|
||
| if (batch.length === 0) { | ||
| return { success: true, summary: 'no executable issues' }; | ||
| if (eligible.length === 0) { | ||
| return { success: true, summary: 'no squad-labeled issues found' }; | ||
| } | ||
|
|
||
| const results = await Promise.all( | ||
| batch.map(issue => executeOne(issue, context, timeout)), | ||
| ); | ||
| const succeeded = results.filter(r => r.success).length; | ||
| const failed = results.length - succeeded; | ||
| // Single agent invocation with all issues — agent reads ralph-instructions.md | ||
| const result = await executeAll(eligible, context, timeout); | ||
|
|
||
| return { | ||
| success: true, | ||
| summary: `${succeeded} executed, ${failed} failed`, | ||
| data: { executed: succeeded, failed }, | ||
| success: result.success, | ||
| summary: result.success | ||
| ? `agent dispatched with ${eligible.length} issues` | ||
| : `agent failed: ${result.error}`, | ||
| data: { dispatched: eligible.length, success: result.success }, | ||
| }; | ||
| } catch (e) { | ||
| return { success: false, summary: `execute error: ${(e as Error).message}` }; | ||
| } | ||
| } | ||
| } | ||
| } | ||
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.
--dispatch-modeis cast to the union type without runtime validation. If a user passes an invalid value, it will silently flow into config and later behave like task mode (because it won’t matchfleet|hybrid), which is hard to diagnose. Consider validating against{task,fleet,hybrid}and either erroring or warning + defaulting totask(and, iffleet|hybrid, auto-enablefleet-dispatchto match the documented usage).