From c762a84b2ebc8b6de77b13c22a04955f76a1ad0b Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 17 Jul 2026 00:40:28 +0200 Subject: [PATCH] fix: scope repo agent names --- src/orchestrator/factory.test.ts | 254 ++++++++++++++++++++++++++++++- src/orchestrator/factory.ts | 106 ++++++------- src/triage/agent-names.ts | 70 +++++++++ src/triage/heuristic.ts | 36 ++--- src/triage/llm.ts | 5 +- src/triage/triage.test.ts | 26 +++- 6 files changed, 415 insertions(+), 82 deletions(-) create mode 100644 src/triage/agent-names.ts diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 1a6565d..9e5a409 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -61,6 +61,23 @@ const config = (overrides: FactoryConfigOverrides = {}): FactoryConfig => Factor ...overrides, }) +const multiRepoGithubConfig = (overrides: FactoryConfigOverrides = {}): FactoryConfig => config({ + issueSource: 'github', + batchSize: 4, + repos: { + byLabel: { + pear: 'AgentWorkforce/pear', + hoopsheet: 'AgentWorkforce/hoopsheet', + }, + clonePaths: { + 'AgentWorkforce/pear': '/work/pear', + 'AgentWorkforce/hoopsheet': '/work/hoopsheet', + }, + default: 'AgentWorkforce/pear', + }, + ...overrides, +}) + const issuePath = (n: number) => `/linear/issues/AR-${n}__uuid-${n}.json` const readyAliasPath = (n: number) => `/linear/issues/by-state/ready-for-agent/AR-${n}.json` const githubIssuePath = (owner: string, repo: string, number: number) => `/github/repos/${owner}/${repo}/issues/by-id/${number}.json` @@ -1320,7 +1337,7 @@ describe('FactoryLoop', () => { expect(report.pulled).toEqual([{ uuid: 'AgentWorkforce/pear#48', key: '48', path }]) expect(report.dispatched.map((result) => result.issue.key)).toEqual(['48']) - expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-48-impl-pear', 'ar-48-review']) + expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-48-impl-pear', 'ar-48-review-pear']) expect(githubWriteback.statuses).toEqual([{ key: '48', status: 'in-progress' }]) expect(githubWriteback.comments[0]?.body).toContain('Factory dispatch for 48') expect(mount.writes.filter((write) => write.path.startsWith('/linear/'))).toEqual([]) @@ -1340,6 +1357,114 @@ describe('FactoryLoop', () => { expect(mergeGate.merges).toEqual([]) }) + it('isolates equal-number GitHub dispatch names, state, registry, resume, and completion across repos', async () => { + const number = 26 + const pearPath = githubIssuePath('AgentWorkforce', 'pear', number) + const hoopsheetPath = githubIssuePath('AgentWorkforce', 'hoopsheet', number) + const pearFile = githubIssueFile(number, { repo: 'pear', labels: ['factory'] }) + const hoopsheetFile = githubIssueFile(number, { repo: 'hoopsheet', labels: ['factory'] }) + const mount = new FakeMountClient({ + [pearPath]: pearFile, + [hoopsheetPath]: hoopsheetFile, + }) + mount.setSubRoot('/linear/issues', 'absent') + const fleet = new FakeFleetClient() + fleet.setSessionRef('ar-26-review-hoopsheet', 'session-review-hoopsheet-26') + const stateStore = new InMemoryStateStore({ batchSize: 4 }) + const temporaryDir = await mkdtemp(join(tmpdir(), 'factory-issue82-registry-')) + const registryPath = join(temporaryDir, 'registry.json') + const factory = createFactory(multiRepoGithubConfig({ loop: { registryPath } }), { + mount, + fleet, + stateStore, + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + }) + + try { + const report = await factory.runOnce() + + expect(report.dispatched.map((result) => result.issue.path).sort()).toEqual([hoopsheetPath, pearPath].sort()) + expect(fleet.spawns.map((spawn) => spawn.name).sort()).toEqual([ + 'ar-26-impl-hoopsheet', + 'ar-26-review-hoopsheet', + 'ar-26-impl-pear', + 'ar-26-review-pear', + ].sort()) + expect(fleet.messages.find((message) => message.to === 'ar-26-impl-pear')?.text) + .toContain('reviewer `ar-26-review-pear`') + expect(fleet.messages.find((message) => message.to === 'ar-26-impl-hoopsheet')?.text) + .toContain('reviewer `ar-26-review-hoopsheet`') + + const registry = await readFactoryInFlightRegistry(registryPath) + expect(registry?.agents.map((agent) => agent.name).sort()).toEqual([ + 'ar-26-impl-hoopsheet', + 'ar-26-review-hoopsheet', + 'ar-26-impl-pear', + 'ar-26-review-pear', + ].sort()) + + fleet.emitAgentExit('ar-26-review-hoopsheet', 'crash') + await vi.waitFor(() => expect(fleet.resumes).toContainEqual({ + name: 'ar-26-review-hoopsheet', + sessionRef: 'session-review-hoopsheet-26', + node: 'self', + capability: 'spawn:claude', + })) + + fleet.emitAgentExit('ar-26-impl-pear', 'issue-done') + await vi.waitFor(() => expect(factory.status().inFlight.map((issue) => issue.path)).toEqual([hoopsheetPath])) + + const pearIssue = parseGithubFactoryIssue(pearPath, pearFile) + const hoopsheetIssue = parseGithubFactoryIssue(hoopsheetPath, hoopsheetFile) + await expect(stateStore.getDispatchAttempts('factory-test', issueKey(pearIssue))).resolves.toMatchObject({ + inFlight: false, + terminal: true, + }) + await expect(stateStore.getDispatchAttempts('factory-test', issueKey(hoopsheetIssue))).resolves.toMatchObject({ + inFlight: true, + terminal: false, + }) + expect(fleet.releases.map((release) => release.name)).toEqual([ + 'ar-26-impl-pear', + 'ar-26-review-pear', + ]) + expect((await readFactoryInFlightRegistry(registryPath))?.agents.map((agent) => agent.name).sort()).toEqual([ + 'ar-26-impl-hoopsheet', + 'ar-26-review-hoopsheet', + ]) + } finally { + await rm(temporaryDir, { recursive: true, force: true }) + } + }) + + it('repo-qualifies equal-number GitHub workflow identities', async () => { + const number = 27 + const pearPath = githubIssuePath('AgentWorkforce', 'pear', number) + const hoopsheetPath = githubIssuePath('AgentWorkforce', 'hoopsheet', number) + const mount = new FakeMountClient({ + [pearPath]: githubIssueFile(number, { repo: 'pear', labels: ['factory', 'pear', 'agent:workflow'] }), + [hoopsheetPath]: githubIssueFile(number, { repo: 'hoopsheet', labels: ['factory', 'hoopsheet', 'agent:workflow'] }), + }) + mount.setSubRoot('/linear/issues', 'absent') + const fleet = new FakeFleetClient() + const factory = createFactory(multiRepoGithubConfig(), { + mount, + fleet, + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + }) + + const report = await factory.runOnce() + + expect(report.dispatched).toHaveLength(2) + expect(fleet.spawns.map((spawn) => spawn.name).sort()).toEqual([ + 'ar-27-workflow-hoopsheet', + 'ar-27-workflow-pear', + ]) + expect(report.dispatched.flatMap((result) => result.agents).every((agent) => agent.role === 'workflow')).toBe(true) + }) + it('requires the configured readiness label before dispatching a GitHub-native issue', async () => { const path = githubIssuePath('AgentWorkforce', 'pear', 49) const mount = new FakeMountClient({ @@ -8038,7 +8163,7 @@ describe('FactoryLoop', () => { author: { login: 'issue-author' }, }) - await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-58-impl-pear', 'ar-58-review'])) + await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-58-impl-pear', 'ar-58-review-pear'])) await vi.waitFor(() => expect(fleet.inputs).toContainEqual({ name: 'ar-58-impl-pear', data: '\nHuman reply on the GitHub issue:\nUse the shared retry helper and add regression coverage.\n\r', @@ -8118,7 +8243,7 @@ describe('FactoryLoop', () => { body: `${prefix} The reporter-authorized clarification.`, author: { login: 'reporter' }, }) - await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-61-impl-pear', 'ar-61-review'])) + await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-61-impl-pear', 'ar-61-review-pear'])) }) it('serializes out-of-order GitHub comments without losing a lower-id correlated reply', async () => { @@ -8148,7 +8273,7 @@ describe('FactoryLoop', () => { author: { login: 'reporter' }, }) - await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-65-impl-pear', 'ar-65-review'])) + await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-65-impl-pear', 'ar-65-review-pear'])) await vi.waitFor(() => expect(factory.status().counters.githubTriageAnswersDispatched).toBe(1)) }) @@ -8254,7 +8379,7 @@ describe('FactoryLoop', () => { liveSubscription: { transport: 'subscribe' }, }) - await vi.waitFor(() => expect(restartFleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-62-impl-pear', 'ar-62-review'])) + await vi.waitFor(() => expect(restartFleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-62-impl-pear', 'ar-62-review-pear'])) expect(restartedFactory.status().counters.githubIssueCommentWatchersRearmed).toBe(1) expect(await restartedStateStore.listGithubIssueCommentWatches('factory-test')).toEqual([]) await restartedFactory.stop() @@ -10027,6 +10152,125 @@ describe('FactoryLoop PR babysitter', () => { }) } + it('keeps equal-number GitHub babysitters and reviewer prompts isolated across repos', async () => { + const number = 26 + const pearPath = githubIssuePath('AgentWorkforce', 'pear', number) + const hoopsheetPath = githubIssuePath('AgentWorkforce', 'hoopsheet', number) + const mount = new FakeMountClient({ + [pearPath]: githubIssueFile(number, { repo: 'pear', labels: ['factory'] }), + [hoopsheetPath]: githubIssueFile(number, { repo: 'hoopsheet', labels: ['factory'] }), + }) + mount.setSubRoot('/linear/issues', 'absent') + seedPrMeta(mount, 'AgentWorkforce/pear', number, { state: 'open', draft: false }) + seedPrMeta(mount, 'AgentWorkforce/hoopsheet', number, { state: 'open', draft: false }) + const fleet = new FakeFleetClient() + const factory = createFactory(multiRepoGithubConfig({ + babysitter: { enabled: true }, + terminalState: 'human-review', + }), { + mount, + fleet, + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + probePrResolver: async (issue) => ({ + repo: issue.path.includes('/hoopsheet/') ? 'AgentWorkforce/hoopsheet' : 'AgentWorkforce/pear', + prNumber: number, + }), + }) + + await factory.runOnce() + fleet.emitAgentExit('ar-26-impl-pear', 'worker_exited') + fleet.emitAgentExit('ar-26-impl-hoopsheet', 'worker_exited') + + await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(expect.arrayContaining([ + 'ar-26-babysit-pear', + 'ar-26-babysit-hoopsheet', + ]))) + expect(fleet.spawns.find((spawn) => spawn.name === 'ar-26-babysit-pear')?.task) + .toContain('reviewer `ar-26-review-pear`') + expect(fleet.spawns.find((spawn) => spawn.name === 'ar-26-babysit-hoopsheet')?.task) + .toContain('reviewer `ar-26-review-hoopsheet`') + + fleet.emitAgentExit('ar-26-impl-pear', 'worker_exited') + await flush() + expect(fleet.spawns.filter((spawn) => spawn.name === 'ar-26-babysit-pear')).toHaveLength(1) + expect(fleet.spawns.filter((spawn) => spawn.name === 'ar-26-babysit-hoopsheet')).toHaveLength(1) + + fleet.emitAgentMessage({ + from: 'ar-26-babysit-pear', + target: 'factory', + body: '[factory-pr-ready] 26', + }) + await vi.waitFor(() => expect(factory.status().inFlight.map((issue) => issue.path)).toEqual([hoopsheetPath])) + expect(fleet.releases.map((release) => release.name).sort()).toEqual([ + 'ar-26-babysit-pear', + 'ar-26-impl-pear', + 'ar-26-review-pear', + ]) + }) + + it('cleans only the completed repo publication guard for equal-number GitHub issues', async () => { + const number = 29 + const pearPath = githubIssuePath('AgentWorkforce', 'pear', number) + const hoopsheetPath = githubIssuePath('AgentWorkforce', 'hoopsheet', number) + const publishInputs: GithubPublishPullRequestInput[] = [] + const githubWrite: GithubConnectionWrite = { + publishPullRequest: async (input) => { + publishInputs.push(input) + return { + repo: input.repo, + number, + url: `https://github.com/${input.repo}/pull/${number}`, + headRef: `ar-${number}-impl-${input.repo.split('/').at(-1)}`, + } + }, + closePullRequest: async () => undefined, + } + const mount = new FakeMountClient({ + [pearPath]: githubIssueFile(number, { repo: 'pear', labels: ['factory'] }), + [hoopsheetPath]: githubIssueFile(number, { repo: 'hoopsheet', labels: ['factory'] }), + '/github/repos/AgentWorkforce/pear/meta.json': { default_branch: 'main' }, + '/github/repos/AgentWorkforce/hoopsheet/meta.json': { default_branch: 'main' }, + }, githubWrite) + mount.setSubRoot('/linear/issues', 'absent') + seedPrMeta(mount, 'AgentWorkforce/pear', number, { state: 'open', draft: false }) + seedPrMeta(mount, 'AgentWorkforce/hoopsheet', number, { state: 'open', draft: false }) + const fleet = new FakeFleetClient() + const factory = createFactory(multiRepoGithubConfig({ + babysitter: { enabled: true }, + terminalState: 'human-review', + }), { + mount, + fleet, + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + probePrResolver: async () => undefined, + }) + + await factory.runOnce() + fleet.emitAgentExit('ar-29-impl-pear', 'crash') + fleet.emitAgentExit('ar-29-impl-hoopsheet', 'crash') + await vi.waitFor(() => expect(publishInputs).toHaveLength(2)) + await vi.waitFor(() => expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(expect.arrayContaining([ + 'ar-29-babysit-pear', + 'ar-29-babysit-hoopsheet', + ]))) + + fleet.emitAgentMessage({ + from: 'ar-29-babysit-pear', + target: 'factory', + body: '[factory-pr-ready] 29', + }) + await vi.waitFor(() => expect(factory.status().inFlight.map((issue) => issue.path)).toEqual([hoopsheetPath])) + + fleet.emitAgentExit('ar-29-impl-hoopsheet', 'crash-again') + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(publishInputs.map((input) => input.repo).sort()).toEqual([ + 'AgentWorkforce/hoopsheet', + 'AgentWorkforce/pear', + ]) + }) + it('spawns a sonnet babysitter (not done) when an implementer exits with a ready PR', async () => { const issue = realIssueFile(401, ready, { title: 'Real babysitter spawn' }) const mount = new FakeMountClient({ [issuePath(401)]: issue }) diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index cdc8170..1acaa63 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -39,6 +39,7 @@ import { } from '@agent-relay/integration-prompts' import { renderAgentTask } from '../dispatch/templates' import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage' +import { agentNameForRole } from '../triage/agent-names' import type { DispatchResult, Factory, @@ -290,10 +291,10 @@ export class FactoryLoop implements Factory { #completionSweepTimer?: ReturnType #completionSweepActive = false readonly #completionInFlight = new Set() - // Issue keys for which a babysitter has already been spawned, so repeated PR + // Composite issue identities for which a babysitter has already been spawned, so repeated PR // webhooks / agent-exit safety nets don't respawn it. readonly #babysitterSpawned = new Set() - // Issue key -> the open PR the babysitter is shepherding, including the + // Composite issue identity -> the open PR the babysitter is shepherding, including the // webhook-fed mount path so readiness can re-read PR meta without a gh call. readonly #babysitterPr = new Map() readonly #publishedPullRequests = new Set() @@ -1085,7 +1086,7 @@ export class FactoryLoop implements Factory { } if (pr.draft) { this.#increment('completionSweepDraftPr') - this.#probePrGhBackoffUntilMs.set(issue.key, this.#clock.now() + PROBE_PR_GH_BACKOFF_MS) + this.#probePrGhBackoffUntilMs.set(issueStateKey(issueRef(issue)), this.#clock.now() + PROBE_PR_GH_BACKOFF_MS) return undefined } return { record, pr } @@ -1131,7 +1132,7 @@ export class FactoryLoop implements Factory { issue: LinearIssue, opts: { requireTitleMarker?: boolean; titleMarker?: string } = {}, ): Promise { - const key = issue.key + const key = issueStateKey(issueRef(issue)) const now = this.#clock.now() const cached = this.#probePrResolvedCache.get(key) if (cached && cached.expiresAtMs > now) { @@ -1448,7 +1449,7 @@ export class FactoryLoop implements Factory { async dispatch(decision: TriageDecision, opts: { dryRun?: boolean } = {}): Promise { const dryRun = opts.dryRun ?? this.#config.dryRun const phase = triageEscalationReason(decision) ? 'escalation' : 'dispatch' - const key = `${decision.issue.key}:${dryRun ? 'dry-run' : 'live'}:${phase}` + const key = `${issueStateKey(decision.issue)}:${dryRun ? 'dry-run' : 'live'}:${phase}` const inFlight = this.#dispatchInFlight.get(key) if (inFlight) { this.#increment('dispatchDuplicateSuppressed') @@ -1514,12 +1515,13 @@ export class FactoryLoop implements Factory { }) if (!dryRun) { const signature = labelDispatchFailureSignature(labelDispatch) - if (this.#labelDispatchFailures.get(decision.issue.key) !== signature) { + const failureKey = issueStateKey(decision.issue) + if (this.#labelDispatchFailures.get(failureKey) !== signature) { try { await this.#postIssueComment(liveIssue, comment) // Record only after a successful post so a failed writeback retries // next cycle rather than being suppressed as already-notified. - this.#labelDispatchFailures.set(decision.issue.key, signature) + this.#labelDispatchFailures.set(failureKey, signature) } catch (error) { this.#logger.warn?.('[factory] label dispatch block comment writeback skipped', error) } @@ -1531,7 +1533,7 @@ export class FactoryLoop implements Factory { const dispatchDecision = labelDispatch.decision // A valid label resolution clears any prior failure notice so a later // regression posts a fresh, actionable comment instead of being deduped. - this.#labelDispatchFailures.delete(dispatchDecision.issue.key) + this.#labelDispatchFailures.delete(issueStateKey(dispatchDecision.issue)) await this.#recordDispatchAttempt(dispatchDecision.issue) const record = batch.start(dispatchDecision, dryRun) if (!record) { @@ -2054,7 +2056,7 @@ export class FactoryLoop implements Factory { } async #dispatchBlockReason(issue: IssueRef): Promise { - const key = issue.key + const key = issueStateKey(issue) const state = await this.#state.getDispatchAttempts(this.#workspaceId, key) if (!state) return undefined if (state.terminal) return 'dispatch already terminal' @@ -2072,7 +2074,7 @@ export class FactoryLoop implements Factory { } async #recordDispatchAttempt(issue: IssueRef): Promise { - const key = issue.key + const key = issueStateKey(issue) const state = await this.#state.getDispatchAttempts(this.#workspaceId, key) ?? { attempts: 0, inFlight: false, @@ -2086,27 +2088,29 @@ export class FactoryLoop implements Factory { } async #clearDispatchInFlight(issue: IssueRef): Promise { - await this.#state.releaseInFlight(this.#workspaceId, issue.key) + await this.#state.releaseInFlight(this.#workspaceId, issueStateKey(issue)) } async #recordDispatchFailure(issue: IssueRef): Promise { - const state = await this.#state.getDispatchAttempts(this.#workspaceId, issue.key) + const key = issueStateKey(issue) + const state = await this.#state.getDispatchAttempts(this.#workspaceId, key) if (!state) return state.inFlight = false if (state.attempts >= this.#config.dispatch.maxAttempts) { state.terminal = true state.backoffUntilMs = 0 this.#increment('dispatchTerminalFailures') - await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, state) + await this.#state.recordDispatchAttempt(this.#workspaceId, key, state) return } state.backoffUntilMs = this.#clock.now() + this.#config.dispatch.errorCooldownMs - await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, state) + await this.#state.recordDispatchAttempt(this.#workspaceId, key, state) this.#increment('dispatchBackoffs') } async #recordDispatchTerminal(issue: IssueRef): Promise { - const state = await this.#state.getDispatchAttempts(this.#workspaceId, issue.key) ?? { + const key = issueStateKey(issue) + const state = await this.#state.getDispatchAttempts(this.#workspaceId, key) ?? { attempts: 0, inFlight: false, terminal: false, @@ -2115,25 +2119,26 @@ export class FactoryLoop implements Factory { state.inFlight = false state.terminal = true state.backoffUntilMs = 0 - await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, state) + await this.#state.recordDispatchAttempt(this.#workspaceId, key, state) } - async #recordCanonicalIssueState(issue: Pick): Promise { - const previousStateId = await this.#state.getCanonicalState(this.#workspaceId, issue.key) + async #recordCanonicalIssueState(issue: Pick): Promise { + const key = issueStateKey(issue) + const previousStateId = await this.#state.getCanonicalState(this.#workspaceId, key) const previousRole = this.#states.roleOf(previousStateId) const reopenedFromTerminal = previousRole === 'done' || previousRole === 'humanReview' if (reopenedFromTerminal && this.#states.isRole(issue.stateId, 'readyForAgent')) { - const dispatchState = await this.#state.getDispatchAttempts(this.#workspaceId, issue.key) + const dispatchState = await this.#state.getDispatchAttempts(this.#workspaceId, key) if (dispatchState?.terminal) { dispatchState.attempts = 0 dispatchState.inFlight = false dispatchState.terminal = false dispatchState.backoffUntilMs = 0 - await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, dispatchState) + await this.#state.recordDispatchAttempt(this.#workspaceId, key, dispatchState) this.#increment('dispatchTerminalReopened') } } - await this.#state.recordCanonicalState(this.#workspaceId, issue.key, issue.stateId) + await this.#state.recordCanonicalState(this.#workspaceId, key, issue.stateId) } async #writeLoopHeartbeat( @@ -4028,7 +4033,7 @@ export class FactoryLoop implements Factory { this.#increment('mergedPrAdvanceNoIssue') return } - const advanceKey = `${issue.key}:${snapshot.number}` + const advanceKey = `${issueKey(issueRef(issue))}:${snapshot.number}` if (this.#postMergeDoneAdvances.has(advanceKey)) { this.#increment('mergedPrAdvanceDuplicatesSuppressed') return @@ -4045,7 +4050,7 @@ export class FactoryLoop implements Factory { } else { const doneStateId = this.#states.idFor(issue.team, 'done') await this.#linear.setState(issue, doneStateId) - await this.#recordCanonicalIssueState({ key: issue.key, stateId: doneStateId }) + await this.#recordCanonicalIssueState({ ...issueRef(issue), stateId: doneStateId }) } this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path }) this.#increment('mergedPrAdvancedDone') @@ -4124,7 +4129,7 @@ export class FactoryLoop implements Factory { // probe resolver and spawn the babysitter. Triggered by an implementer exiting // after opening its PR (an event, not a poll). async #ensureBabysitterForIssue(record: InFlightIssue): Promise { - if (this.#babysitterSpawned.has(record.issue.key)) { + if (this.#babysitterSpawned.has(issueKey(record.issue))) { return } const issue = await this.#readIssue(record.issue.path) @@ -4139,21 +4144,22 @@ export class FactoryLoop implements Factory { } async #ensureBabysitter(record: InFlightIssue, prRef: { repo: string; prNumber: number; url?: string; path?: string }): Promise { - this.#babysitterPr.set(record.issue.key, { repo: prRef.repo, prNumber: prRef.prNumber, path: prRef.path }) - if (this.#babysitterSpawned.has(record.issue.key)) { + const babysitterKey = issueKey(record.issue) + this.#babysitterPr.set(babysitterKey, { repo: prRef.repo, prNumber: prRef.prNumber, path: prRef.path }) + if (this.#babysitterSpawned.has(babysitterKey)) { return } if ([...record.agents.values()].some((agent) => agent.spec.role === 'babysitter')) { - this.#babysitterSpawned.add(record.issue.key) + this.#babysitterSpawned.add(babysitterKey) return } // Reserve up-front so concurrent PR events in a drain don't double-spawn. - this.#babysitterSpawned.add(record.issue.key) + this.#babysitterSpawned.add(babysitterKey) try { const issue = await this.#readIssue(record.issue.path) if (!issue) { - this.#babysitterSpawned.delete(record.issue.key) + this.#babysitterSpawned.delete(babysitterKey) return } @@ -4161,7 +4167,8 @@ export class FactoryLoop implements Factory { ?? record.decision.routes[0] const spec = babysitterSpec(issue, this.#config, route) const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer') - const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name ?? `${spec.name.replace(/-babysit$/, '')}-review` + const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name + ?? agentNameForRole(issue, 'review', { repo: route?.repo ?? prRef.repo }) const implementerNames = [...record.agents.values()] .filter((agent) => agent.spec.role === 'implementer') .map((agent) => agent.result?.name ?? agent.spec.name) @@ -4202,7 +4209,7 @@ export class FactoryLoop implements Factory { } } catch (error) { // Allow a later event to retry the spawn. - this.#babysitterSpawned.delete(record.issue.key) + this.#babysitterSpawned.delete(babysitterKey) this.#increment('babysitterSpawnFailures') this.#error(error, record.issue) } @@ -4244,7 +4251,7 @@ export class FactoryLoop implements Factory { // exact path captured when the babysitter was spawned; otherwise scans the // repo's pulls subtree for the PR number across known layout shapes. async #readBabysatPrSnapshot(record: InFlightIssue): Promise { - const ref = this.#babysitterPr.get(record.issue.key) + const ref = this.#babysitterPr.get(issueKey(record.issue)) if (!ref) { return undefined } @@ -4291,7 +4298,7 @@ export class FactoryLoop implements Factory { return true } - const pr = this.#babysitterPr.get(record.issue.key) ?? await this.#completionPrForIssue(issue) + const pr = this.#babysitterPr.get(issueKey(record.issue)) ?? await this.#completionPrForIssue(issue) if (!pr) { return false } @@ -4365,7 +4372,7 @@ export class FactoryLoop implements Factory { ? this.#states.idFor(issueTeam, 'humanReview') : this.#states.idFor(issueTeam, 'done') await this.#linear.setState(issue, targetState) - await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState }) + await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState }) } this.#emit('writeback-verified', { issue: record.issue, path: issue.path }) } @@ -4419,12 +4426,13 @@ export class FactoryLoop implements Factory { this.#error(error, record.issue) } finally { this.#completionInFlight.delete(completionKey) - this.#probePrGhBackoffUntilMs.delete(completionKey) - this.#probePrResolvedCache.delete(completionKey) - this.#babysitterSpawned.delete(record.issue.key) - this.#babysitterPr.delete(record.issue.key) + const stateKey = issueStateKey(record.issue) + this.#probePrGhBackoffUntilMs.delete(stateKey) + this.#probePrResolvedCache.delete(stateKey) + this.#babysitterSpawned.delete(completionKey) + this.#babysitterPr.delete(completionKey) for (const publishedKey of this.#publishedPullRequests) { - if (publishedKey.startsWith(`${record.issue.key}:`)) this.#publishedPullRequests.delete(publishedKey) + if (publishedKey.startsWith(`${completionKey}:`)) this.#publishedPullRequests.delete(publishedKey) } } } @@ -6179,6 +6187,11 @@ const githubIssueAuthor = (issue: LinearIssue): string | undefined => { const issueRef = (issue: LinearIssue): IssueRef => ({ uuid: issue.uuid, key: issue.key, path: issue.path }) +// Preserve the historical Linear state namespace while keeping GitHub-native +// issue numbers independent across repositories in the same workspace. +const issueStateKey = (issue: IssueRef): string => + githubIssuePathParts(issue.path) ? issueKey(issue) : issue.key + const pidsFromSpawnResult = (result: { pid?: number; pids?: number[] } | undefined): number[] => { const pids = new Set() for (const pid of result?.pids ?? []) { @@ -6440,7 +6453,7 @@ function routeImplementerSpec( route: TriageDecision['routes'][number], ): AgentSpec { return { - name: `${agentBaseName(issue)}-impl-${sanitizeAgentSlug(slug)}`, + name: agentNameForRole(issue, 'impl', { repo: route.repo, discriminator: slug }), role: 'implementer', capability: 'spawn:codex', model: config.models.implementer, @@ -6459,7 +6472,7 @@ function routeReviewerSpec( ): AgentSpec { return { ...reviewer, - name: `${agentBaseName(issue)}-review`, + name: agentNameForRole(issue, 'review', { repo: route.repo }), role: 'reviewer', capability: reviewer.capability ?? 'spawn:claude', model: reviewer.model ?? config.models.reviewer, @@ -6479,7 +6492,7 @@ function routeWorkflowSpec( const route = routesByLabel[0]!.route return { ...workflow, - name: workflow?.name ?? `${agentBaseName(issue)}-workflow`, + name: agentNameForRole(issue, 'workflow', { repo: route.repo }), role: 'workflow', capability: 'workflow:run', task: workflow?.task ?? taskForDispatch(issue, route, 'workflow'), @@ -6563,15 +6576,6 @@ function taskForDispatch(issue: LinearIssue, route: TriageDecision['routes'][num ].join('\n\n') } -function agentBaseName(issue: LinearIssue): string { - const number = issue.key.match(/\d+/)?.[0] ?? sanitizeAgentSlug(issue.key) - return `ar-${number}` -} - -function sanitizeAgentSlug(slug: string): string { - return slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'scope' -} - const templateIssueFromRecord = (record: InFlightIssue, issue: LinearIssue | undefined) => ({ key: issue?.key ?? record.issue.key, title: issue?.title ?? record.issue.key, diff --git a/src/triage/agent-names.ts b/src/triage/agent-names.ts new file mode 100644 index 0000000..3f39408 --- /dev/null +++ b/src/triage/agent-names.ts @@ -0,0 +1,70 @@ +import type { LinearIssue } from '../types' + +export type FactoryAgentRoleName = 'impl' | 'review' | 'babysit' | 'workflow' + +type AgentIssue = Pick + +export function agentNameForRole( + issue: AgentIssue, + role: FactoryAgentRoleName, + options: { repo?: string; discriminator?: string } = {}, +): string { + const suffixes: string[] = [] + if (options.discriminator) { + suffixes.push(sanitizeAgentSlug(options.discriminator)) + } + + if (isGithubNativeIssue(issue)) { + const repoSlug = githubNativeRepoSlug(issue) ?? repoSlugFromName(options.repo) + if (repoSlug && !suffixes.some((suffix) => suffix === repoSlug || suffix.endsWith(`-${repoSlug}`))) { + suffixes.push(repoSlug) + } + } + + return [agentBaseName(issue), role, ...suffixes].join('-') +} + +export function agentBaseName(issue: Pick): string { + const number = issue.key.match(/\d+/u)?.[0] ?? sanitizeAgentSlug(issue.key) + return `ar-${number}` +} + +export function repoSlugFromName(repo: string | undefined): string | undefined { + if (!repo?.trim()) return undefined + return sanitizeAgentSlug(repo.split('/').at(-1) ?? repo) +} + +export function sanitizeAgentSlug(slug: string): string { + return slug.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '') || 'scope' +} + +export function isGithubNativeIssue(issue: Pick): boolean { + if (!/^\d+$/u.test(issue.key)) return false + if (issue.path.startsWith('/github/repos/')) return true + return stringValue(asRecord(issue.raw)?.provider)?.toLowerCase() === 'github' +} + +function githubNativeRepoSlug(issue: AgentIssue): string | undefined { + const wrapper = asRecord(issue.raw) + const payload = asRecord(wrapper?.payload) ?? wrapper + const source = asRecord(payload?.source) + const sourceRepo = stringValue(source?.repo) + if (sourceRepo) return repoSlugFromName(sourceRepo) + + const repository = asRecord(payload?.repository) + const repositoryName = stringValue(repository?.name) + if (repositoryName) return repoSlugFromName(repositoryName) + + const compact = issue.path.match(/^\/github\/repos\/[^/]+__([^/]+)\/issues\//u) + if (compact?.[1]) return sanitizeAgentSlug(compact[1]) + const nested = issue.path.match(/^\/github\/repos\/[^/]+\/([^/]+)\/issues\//u) + return nested?.[1] ? sanitizeAgentSlug(nested[1]) : undefined +} + +const asRecord = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined + +const stringValue = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() ? value.trim() : undefined diff --git a/src/triage/heuristic.ts b/src/triage/heuristic.ts index 7d56760..2fcb270 100644 --- a/src/triage/heuristic.ts +++ b/src/triage/heuristic.ts @@ -1,6 +1,7 @@ import type { FactoryConfig } from '../config/schema' import type { AgentSpec } from '../ports' import type { IssueRef, LinearIssue, RepoMapEntry, TriageContext, TriageDecision, TriageEngine } from '../types' +import { agentNameForRole, repoSlugFromName } from './agent-names' type RouteSource = RepoMapEntry['source'] type Route = TriageDecision['routes'][number] @@ -63,7 +64,7 @@ export class HeuristicTriage implements TriageEngine { ? `${routed.rationale} Scope selected from Linear label agent:${explicitScope}.` : routed.rationale, scopeSlugs: routed.routes.length >= 2 - ? routed.routes.map((route) => slugFromRepo(route.repo)) + ? routed.routes.map((route) => repoSlugFromName(route.repo) ?? 'scope') : surfaces, }) } @@ -90,7 +91,7 @@ export function buildDecision(input: { : input.scope === 'workflow' ? 'workflow' : 'single' - const slugs = input.scopeSlugs ?? routes.map((route) => slugFromRepo(route.repo)) + const slugs = input.scopeSlugs ?? routes.map((route) => repoSlugFromName(route.repo) ?? 'scope') const implementerAssignments = scope === 'workflow' || input.confidence === 'low' && routes.length === 0 ? [] : implementationAssignments(routes, scope, slugs, maxImplementers) @@ -248,13 +249,13 @@ function implementationAssignments( ): Array<{ route: Route; slug: string }> { if (scope === 'single' || scope === 'workflow') { const route = routes[0] - return route ? [{ route, slug: slugFromRepo(route.repo) }] : [] + return route ? [{ route, slug: repoSlugFromName(route.repo) ?? 'scope' }] : [] } if (routes.length >= 2) { return routes.slice(0, maxImplementers).map((route, index) => ({ route, - slug: slugs[index] ?? slugFromRepo(route.repo), + slug: slugs[index] ?? repoSlugFromName(route.repo) ?? 'scope', })) } @@ -263,7 +264,7 @@ function implementationAssignments( return [] } - const teamSlugs = slugs.length >= 2 ? slugs : [slugFromRepo(route.repo), 'scope'] + const teamSlugs = slugs.length >= 2 ? slugs : [repoSlugFromName(route.repo) ?? 'scope', 'scope'] return teamSlugs.slice(0, maxImplementers).map((slug) => ({ route, slug })) } @@ -282,8 +283,10 @@ function implementerSpec(input: { scope: Scope slug: string }): AgentSpec { - const base = agentBaseName(input.issue) - const name = input.scope === 'team' ? `${base}-impl-${sanitizeSlug(input.slug)}` : `${base}-impl` + const name = agentNameForRole(input.issue, 'impl', { + repo: input.route.repo, + discriminator: input.scope === 'team' ? input.slug : undefined, + }) return { name, role: 'implementer', @@ -304,7 +307,7 @@ function workflowSpec(issue: LinearIssue, _config: FactoryConfig, routes: Route[ // consumes this version, so the two can diverge without an observable bug. const repoLabels = issue.labels.filter((label) => !isShapeLabel(label)) return { - name: `${agentBaseName(issue)}-workflow`, + name: agentNameForRole(issue, 'workflow', { repo: primaryRoute.repo }), role: 'workflow', capability: 'workflow:run', task: taskFor(issue, primaryRoute, 'workflow'), @@ -326,7 +329,7 @@ function workflowSpec(issue: LinearIssue, _config: FactoryConfig, routes: Route[ function reviewerSpec(issue: LinearIssue, config: FactoryConfig, route?: Route): AgentSpec { const repo = route?.repo ?? config.repos.default ?? 'unroutable' return { - name: `${agentBaseName(issue)}-review`, + name: agentNameForRole(issue, 'review', { repo }), role: 'reviewer', capability: 'spawn:claude', model: config.models.reviewer, @@ -346,7 +349,7 @@ function reviewerSpec(issue: LinearIssue, config: FactoryConfig, route?: Route): export function babysitterSpec(issue: LinearIssue, config: FactoryConfig, route?: Route): AgentSpec { const repo = route?.repo ?? config.repos.default ?? 'unroutable' return { - name: `${agentBaseName(issue)}-babysit`, + name: agentNameForRole(issue, 'babysit', { repo }), role: 'babysitter', capability: 'spawn:claude', model: config.models.babysitter, @@ -373,19 +376,6 @@ function taskFor(issue: LinearIssue, route: Route, role: AgentSpec['role']): str ].join('\n\n') } -function agentBaseName(issue: LinearIssue): string { - const number = issue.key.match(/\d+/)?.[0] ?? sanitizeSlug(issue.key) - return `ar-${number}` -} - -function slugFromRepo(repo: string): string { - return sanitizeSlug(repo.split('/').at(-1) ?? repo) -} - -function sanitizeSlug(slug: string): string { - return slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'scope' -} - export function scopeFromLabels(labels: string[]): Scope | undefined { const normalized = new Set(labels.map((label) => label.trim().toLowerCase())) if (normalized.has('agent:team')) return 'team' diff --git a/src/triage/llm.ts b/src/triage/llm.ts index d1105a2..bdea930 100644 --- a/src/triage/llm.ts +++ b/src/triage/llm.ts @@ -30,8 +30,9 @@ export function buildPrompt(issue: LinearIssue, ctx: TriageContext): string { '- Execution shape labels are orthogonal to repo labels and win over inference: agent:single => single, agent:workflow => workflow, agent:team => team.', '- Thin issue: description under 140 characters or no acceptance signal.', '- Without an execution shape label, infer single, workflow, or team. Use workflow for issues that describe an existing multi-step workflow or cross-agent orchestration; use team iff two or more surface buckets are named, or two or more routes are needed. Use at most two implementers.', - '- Workflow scope must include workflow { name: ar-{n}-workflow, role: workflow, capability: workflow:run, workflow: workflows/factory/linear-issue.ts, inputs } and no implementers.', - '- Preserve agent naming: ar-{n}-impl, ar-{n}-impl-{scope-or-repo}, ar-{n}-workflow, ar-{n}-review.', + '- Workflow scope must include one workflow role with capability workflow:run, workflow workflows/factory/linear-issue.ts, inputs, and no implementers.', + '- Preserve Linear agent naming: ar-{n}-impl, ar-{n}-impl-{scope-or-repo}, ar-{n}-workflow, ar-{n}-review.', + '- For GitHub-native issues, append the sanitized source repo basename to every broker-global role: ar-{n}-impl-{repo}, ar-{n}-workflow-{repo}, ar-{n}-review-{repo}.', 'Return only JSON matching the TriageDecision schema.', '', `Config repos: ${JSON.stringify(ctx.config.repos)}`, diff --git a/src/triage/triage.test.ts b/src/triage/triage.test.ts index c36c76a..b11bfb1 100644 --- a/src/triage/triage.test.ts +++ b/src/triage/triage.test.ts @@ -3,7 +3,7 @@ import { ZodError } from 'zod' import { FactoryConfigSchema, type FactoryConfig } from '../config/schema' import type { LinearIssue, TriageContext, TriageDecision, TriageEngine } from '../types' -import { HeuristicTriage } from './heuristic' +import { HeuristicTriage, babysitterSpec } from './heuristic' import { LlmTriage } from './llm' import { TieredTriage } from './tiered' @@ -236,6 +236,30 @@ describe('HeuristicTriage thin and scope detection', () => { node: 'self', }) }) + + it('repo-qualifies every collision-prone GitHub-native role while preserving its source repo identity', async () => { + const githubIssue = issue({ + uuid: 'AgentWorkforce/hoopsheet#26', + key: '26', + path: '/github/repos/AgentWorkforce/hoopsheet/issues/by-id/26.json', + raw: { + provider: 'github', + payload: { + source: { provider: 'github', owner: 'AgentWorkforce', repo: 'hoopsheet', number: 26 }, + }, + }, + }) + const decision = await new HeuristicTriage().triage(githubIssue, ctx) + const workflow = await new HeuristicTriage().triage({ + ...githubIssue, + labels: ['pear', 'agent:workflow'], + }, ctx) + + expect(decision.implementers.map((agent) => agent.name)).toEqual(['ar-26-impl-hoopsheet']) + expect(decision.reviewer.name).toBe('ar-26-review-hoopsheet') + expect(babysitterSpec(githubIssue, baseConfig, decision.routes[0]).name).toBe('ar-26-babysit-hoopsheet') + expect(workflow.workflow?.name).toBe('ar-26-workflow-hoopsheet') + }) }) describe('LlmTriage', () => {