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', () => {