Add Grok orchestration group routing (#8058)
* docs: design Grok orchestration group * docs: plan Grok orchestration group implementation * fix: add Grok orchestration group * test(orchestration): accept Windows skill newlines * Fix @grok orchestration group matching and remove stale planning docs - Reuse the shared buildAgentNameRe matcher in groups.ts instead of a divergent local regex, so orchestration groups honor the same Windows launcher-suffix rule (grok.exe/.cmd/.bat/.ps1) as the rest of Orca's agent-title detection. - Add test coverage for real Grok OSC title shapes (spinner-collapsed, session titles) and Windows launcher-suffix titles. - Delete the now-completed design and implementation-plan docs for the Grok orchestration group work. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
parent
92ea918b63
commit
be258e23ec
|
|
@ -11,7 +11,9 @@ function readSkill() {
|
|||
|
||||
function getSection(markdown, heading) {
|
||||
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = markdown.match(new RegExp(`## ${escapedHeading}\\n([\\s\\S]*?)(?=\\n## |$)`))
|
||||
const match = markdown.match(
|
||||
new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)
|
||||
)
|
||||
|
||||
expect(match).not.toBeNull()
|
||||
|
||||
|
|
@ -189,6 +191,13 @@ describe('orchestration skill guidance', () => {
|
|||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
|
||||
it('documents @grok in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
|
||||
expect(messaging).toContain('`@grok`')
|
||||
})
|
||||
|
||||
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ Rules:
|
|||
- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.
|
||||
- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.
|
||||
- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.
|
||||
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, and `@worktree:<id>`.
|
||||
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, and `@worktree:<id>`.
|
||||
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.
|
||||
- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.
|
||||
- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.
|
||||
|
|
|
|||
|
|
@ -120,10 +120,7 @@ export class KimiSessionIndexCache {
|
|||
}
|
||||
}
|
||||
|
||||
function identitiesMatch(
|
||||
left: KimiSessionIndexIdentity,
|
||||
right: KimiSessionIndexIdentity
|
||||
): boolean {
|
||||
function identitiesMatch(left: KimiSessionIndexIdentity, right: KimiSessionIndexIdentity): boolean {
|
||||
return (
|
||||
left.changeTimeMs === right.changeTimeMs &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ describe('submodule path cache', () => {
|
|||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse another branch\'s submodule paths after local or WSL checkout', async () => {
|
||||
it("does not reuse another branch's submodule paths after local or WSL checkout", async () => {
|
||||
let modulePath = 'main-lib'
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'checkout') {
|
||||
|
|
@ -219,9 +219,7 @@ describe('submodule path cache', () => {
|
|||
...runtime,
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual([
|
||||
'recreated-lib'
|
||||
])
|
||||
await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual(['recreated-lib'])
|
||||
|
||||
const configReads = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_WARNING_DEDUPE_MAX_KEYS,
|
||||
shouldEmitBoundedWarning
|
||||
} from './bounded-warning-dedupe'
|
||||
import { DEFAULT_WARNING_DEDUPE_MAX_KEYS, shouldEmitBoundedWarning } from './bounded-warning-dedupe'
|
||||
|
||||
describe('shouldEmitBoundedWarning', () => {
|
||||
it('keeps retained warning keys quiet without cascade eviction after saturation', () => {
|
||||
|
|
@ -29,8 +26,6 @@ describe('shouldEmitBoundedWarning', () => {
|
|||
)
|
||||
|
||||
expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual(keys)
|
||||
expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual([
|
||||
keys.at(-1)
|
||||
])
|
||||
expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual([keys.at(-1)])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -155,10 +155,7 @@ vi.mock('./ssh', () => ({
|
|||
}))
|
||||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
import {
|
||||
clearSubmodulePathsCacheForTests,
|
||||
listSubmodulePaths
|
||||
} from '../git/status'
|
||||
import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status'
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitCapabilityStateForTests()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ describe('isGroupAddress', () => {
|
|||
expect(isGroupAddress('@idle')).toBe(true)
|
||||
expect(isGroupAddress('@claude')).toBe(true)
|
||||
expect(isGroupAddress('@droid')).toBe(true)
|
||||
expect(isGroupAddress('@grok')).toBe(true)
|
||||
expect(isGroupAddress('@worktree:wt_1')).toBe(true)
|
||||
})
|
||||
|
||||
|
|
@ -169,6 +170,54 @@ describe('resolveGroupAddress', () => {
|
|||
const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus)
|
||||
expect(result).toEqual(['term_b'])
|
||||
})
|
||||
|
||||
it('matches @grok as a standalone title token and excludes sender', () => {
|
||||
const terminals = [
|
||||
makeSummary('term_a', { title: 'Grok' }),
|
||||
makeSummary('term_b', { title: 'GROK CLI' }),
|
||||
makeSummary('term_c', { title: '⠋ Grok' }),
|
||||
makeSummary('term_d', { title: 'ngrok' }),
|
||||
makeSummary('term_e', { title: '/tmp/grok' }),
|
||||
makeSummary('term_f', { title: 'my-grok-worker' }),
|
||||
makeSummary('term_g', { title: 'Codex CLI' })
|
||||
]
|
||||
|
||||
const result = resolveGroupAddress('@GrOk', 'term_a', terminals, noStatus)
|
||||
|
||||
expect(result).toEqual(['term_b', 'term_c'])
|
||||
})
|
||||
|
||||
// Why: the resolver sees the raw OSC title, and Grok CLI's real working/session
|
||||
// titles carry a trailing " - grok" identity or a spinner-collapsed "⠋ grok"
|
||||
// (see terminal-title-agent-type.ts). Prove those production shapes resolve.
|
||||
it('matches real Grok OSC working and session titles', () => {
|
||||
const terminals = [
|
||||
makeSummary('coordinator', { title: 'Coordinator' }),
|
||||
makeSummary('term_rotating', { title: '⠋ - fix the flaky suite - grok' }),
|
||||
makeSummary('term_collapsed', { title: '⠋ grok' }),
|
||||
makeSummary('term_session', { title: 'Fix the auth bug - grok' })
|
||||
]
|
||||
|
||||
const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus)
|
||||
|
||||
expect(result).toEqual(['term_rotating', 'term_collapsed', 'term_session'])
|
||||
})
|
||||
|
||||
// Why: Windows agent titles can surface the launcher process name (`grok.exe`);
|
||||
// the shared matcher accepts .exe/.cmd/.bat/.ps1 suffixes but still rejects
|
||||
// arbitrary dotted fragments like `grok.py`.
|
||||
it('matches Windows launcher-suffix titles but not arbitrary dotted tokens', () => {
|
||||
const terminals = [
|
||||
makeSummary('coordinator', { title: 'Coordinator' }),
|
||||
makeSummary('term_exe', { title: 'grok.exe' }),
|
||||
makeSummary('term_cmd', { title: 'grok.cmd running' }),
|
||||
makeSummary('term_dotted', { title: 'grok.py' })
|
||||
]
|
||||
|
||||
const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus)
|
||||
|
||||
expect(result).toEqual(['term_exe', 'term_cmd'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown groups', () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { buildAgentNameRe } from '../../../shared/agent-name-token-match'
|
||||
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
|
||||
|
||||
// Why: group addresses enable broadcast messaging to logical groups of agents.
|
||||
|
|
@ -11,7 +12,8 @@ const AGENT_NAME_GROUPS = [
|
|||
'opencode',
|
||||
'mimo',
|
||||
'gemini',
|
||||
'droid'
|
||||
'droid',
|
||||
'grok'
|
||||
] as const
|
||||
|
||||
export type GroupAddress =
|
||||
|
|
@ -24,13 +26,11 @@ export function isGroupAddress(to: string): boolean {
|
|||
return to.startsWith('@')
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function titleMatchesAgentNameGroup(title: string, agentName: string): boolean {
|
||||
const tokenRe = new RegExp(`(?<![\\w./\\\\-])${escapeRegExp(agentName)}(?![\\w./\\\\-])`, 'i')
|
||||
return tokenRe.test(title)
|
||||
// Why: reuse the shared whole-token matcher so orchestration groups honor the
|
||||
// same Windows launcher-suffix rule (e.g. `grok.exe`) as the rest of Orca's
|
||||
// agent-title detection, instead of maintaining a divergent regex here.
|
||||
return buildAgentNameRe(agentName).test(title)
|
||||
}
|
||||
|
||||
export function resolveGroupAddress(
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ describe('project-picker-browse-cache', () => {
|
|||
0
|
||||
)
|
||||
for (let wave = 0; wave < 4; wave += 1) {
|
||||
expect(
|
||||
getProjectPickerBrowseCacheEntry('runtime:retained', inserted)
|
||||
).toMatchObject({ projects: [expect.objectContaining({ owner: 'retained' })] })
|
||||
expect(getProjectPickerBrowseCacheEntry('runtime:retained', inserted)).toMatchObject({
|
||||
projects: [expect.objectContaining({ owner: 'retained' })]
|
||||
})
|
||||
for (let index = 1; index < PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES; index += 1) {
|
||||
const scope = `scope-${inserted}`
|
||||
rememberProjectPickerBrowseCacheEntry(
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ const { appState, cacheMocks } = vi.hoisted(() => ({
|
|||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: typeof appState.current) => unknown) =>
|
||||
selector(appState.current)
|
||||
useAppStore: (selector: (state: typeof appState.current) => unknown) => selector(appState.current)
|
||||
}))
|
||||
|
||||
vi.mock('./pet-blob-cache', () => ({
|
||||
|
|
|
|||
|
|
@ -137,15 +137,12 @@ export function useLinearAgentSkillSetupReminderToast({
|
|||
}
|
||||
}, [localDismissStorageKey, missingSetup])
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (surface !== 'modal') {
|
||||
return
|
||||
}
|
||||
return () => {
|
||||
dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey)
|
||||
}
|
||||
},
|
||||
[localDismissStorageKey, surface]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (surface !== 'modal') {
|
||||
return
|
||||
}
|
||||
return () => {
|
||||
dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey)
|
||||
}
|
||||
}, [localDismissStorageKey, surface])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ describe('linear agent skill setup reminders', () => {
|
|||
MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS
|
||||
)
|
||||
expect(hasLinearAgentSkillSetupReminderStateForTests('runtime-0')).toBe(false)
|
||||
expect(hasLinearAgentSkillSetupReminderStateForTests(`runtime-${churnedRuntimeCount - 1}`)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
hasLinearAgentSkillSetupReminderStateForTests(`runtime-${churnedRuntimeCount - 1}`)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('retains recently reused keys while trimming', () => {
|
||||
|
|
|
|||
|
|
@ -228,9 +228,9 @@ describe('openMobileEmulatorTab', () => {
|
|||
)
|
||||
|
||||
expect(ensureSimulatorTab).toHaveBeenCalledTimes(2)
|
||||
const attachCalls = vi.mocked(callRuntimeRpc).mock.calls.filter(([, method]) =>
|
||||
method === 'emulator.attach'
|
||||
)
|
||||
const attachCalls = vi
|
||||
.mocked(callRuntimeRpc)
|
||||
.mock.calls.filter(([, method]) => method === 'emulator.attach')
|
||||
expect(attachCalls).toHaveLength(1)
|
||||
expect(isManualSimulatorLaunchPending('wt-1')).toBe(false)
|
||||
})
|
||||
|
|
@ -245,10 +245,12 @@ describe('openMobileEmulatorTab', () => {
|
|||
)
|
||||
vi.mocked(ensureSimulatorTab)
|
||||
.mockImplementationOnce(() => {
|
||||
mockStoreState.unifiedTabsByWorktree['wt-1'] = [{
|
||||
id: 'sim-1',
|
||||
contentType: 'simulator'
|
||||
}]
|
||||
mockStoreState.unifiedTabsByWorktree['wt-1'] = [
|
||||
{
|
||||
id: 'sim-1',
|
||||
contentType: 'simulator'
|
||||
}
|
||||
]
|
||||
return 'sim-1'
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
|
|
|
|||
|
|
@ -54,11 +54,7 @@ export function beginHugeRepoWarningProbe(
|
|||
|
||||
export function hasDismissedHugeRepoWarning(probe: HugeRepoWarningProbe): boolean {
|
||||
const state = hugeRepoWarningStateByWorktreeId.get(probe.worktreeId)
|
||||
if (
|
||||
!state ||
|
||||
state.lifecycleToken !== probe.lifecycleToken ||
|
||||
!state.dismissed
|
||||
) {
|
||||
if (!state || state.lifecycleToken !== probe.lifecycleToken || !state.dismissed) {
|
||||
return false
|
||||
}
|
||||
refreshHugeRepoWarningState(probe.worktreeId, state)
|
||||
|
|
|
|||
|
|
@ -956,7 +956,10 @@ describe('shared agent-hook-listener', () => {
|
|||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hookEventName: 'user_prompt_submit', prompt: '<user_query>fix the bug</user_query>' }
|
||||
payload: {
|
||||
hookEventName: 'user_prompt_submit',
|
||||
prompt: '<user_query>fix the bug</user_query>'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const AGENT_NAMES = [
|
|||
// `openclaude.exe`; still reject arbitrary dotted path fragments.
|
||||
const WINDOWS_EXECUTABLE_SUFFIX_RE = String.raw`(?:\.(?:exe|cmd|bat|ps1))`
|
||||
|
||||
function buildAgentNameRe(name: string): RegExp {
|
||||
export function buildAgentNameRe(name: string): RegExp {
|
||||
return new RegExp(
|
||||
`(?<![\\w./\\\\-])${name}(?:${WINDOWS_EXECUTABLE_SUFFIX_RE})?(?![\\w./\\\\-])`,
|
||||
'i'
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ describe('isKnownHarnessInjectedUserTurnText', () => {
|
|||
isKnownHarnessInjectedUserTurnText('A message arrived from teammate-b:\n<agent-message>hi')
|
||||
).toBe(true)
|
||||
expect(
|
||||
isKnownHarnessInjectedUserTurnText('Another Claude session sent a message:\n<agent-message>hi')
|
||||
isKnownHarnessInjectedUserTurnText(
|
||||
'Another Claude session sent a message:\n<agent-message>hi'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(isKnownHarnessInjectedUserTurnText('No response requested.')).toBe(true)
|
||||
expect(isKnownHarnessInjectedUserTurnText('[Request interrupted by user]')).toBe(true)
|
||||
|
|
@ -78,7 +80,9 @@ describe('isKnownHarnessInjectedUserTurnText', () => {
|
|||
expect(
|
||||
isKnownHarnessInjectedUserTurnText('<script>alert(1)</script> — why is this flagged?')
|
||||
).toBe(false)
|
||||
expect(isKnownHarnessInjectedUserTurnText('<https://example.com/a-b> what is this?')).toBe(false)
|
||||
expect(isKnownHarnessInjectedUserTurnText('<https://example.com/a-b> what is this?')).toBe(
|
||||
false
|
||||
)
|
||||
expect(isKnownHarnessInjectedUserTurnText('<foo-bar@example.com> sent me this')).toBe(false)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue