Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/orchestrator/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4962,6 +4962,65 @@ describe('FactoryLoop', () => {
}
})

// Factory admits an issue to dispatch on EITHER the title prefix or the
// scope label, but orphan recovery demanded the label alone. An issue
// admitted by title could therefore be dispatched and then never un-stuck.
// factory#139 is the live instance: title `[factory] ...`, and its only
// label is `factory:in-progress`.
it('recovers an orphaned in-progress GitHub issue that is in scope by title prefix alone', async () => {
const root = await mkdtemp(join(tmpdir(), 'factory-orphan-title-scope-'))
try {
const path = githubIssueCompactPath('AgentWorkforce', 'pear', 139)
const payload = { title: '[factory-e2e] Title-scoped orphan', labels: ['pear', 'factory:in-progress'] }
// Go through the Relayfile issue index, which is what indexed production
// deployments use. The index carries no title, so a title-scoped row can
// only be retained via its `factory:in-progress` lifecycle label — fixing
// the recovery gate alone leaves this issue unread and still stranded.
const mount = new FakeMountClient({
'/github/repos/AgentWorkforce/pear/issues/_index.json': [
{
id: '139',
number: 139,
title: '[factory-e2e] Title-scoped orphan',
updated: '2026-08-17T10:00:00Z',
state: 'open',
labels: ['pear', 'factory:in-progress'],
},
],
[path]: githubIssueFile(139, payload),
})
mount.setSubRoot('/linear/issues', 'absent')
const fleet = new FakeFleetClient()
const githubWriteback = new RecordingGithubWriteback()
const stateStore = new InMemoryStateStore({ batchSize: 4 })
const issue = parseGithubFactoryIssue(path, githubIssueFile(139, payload))
await stateStore.recordDispatchAttempt('factory-test', issueKey(issue), {
attempts: 1,
inFlight: true,
terminal: false,
backoffUntilMs: 0,
})
const restartedFactory = createFactory(config({
issueSource: 'github',
loop: { registryPath: join(root, 'registry.json') },
}), {
mount,
fleet,
stateStore,
triage: new StaticTriage(),
githubWriteback,
probePrGhRunner: async () => ({ stdout: '[]' }),
})

await restartedFactory.runOnce()

expect(restartedFactory.status().counters.githubOrphanedInProgressRecovered).toBe(1)
expect(githubWriteback.statuses).toContainEqual({ key: '139', status: 'ready' })
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('releases a dead durable GitHub claim and redispatches the issue', async () => {
const root = await mkdtemp(join(tmpdir(), 'factory-dead-lifecycle-claim-'))
try {
Expand Down
28 changes: 21 additions & 7 deletions src/orchestrator/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2931,12 +2931,13 @@ export class FactoryLoop implements Factory {
const labels = isGithubIssue(issue)
? new Set(issue.labels.map((label) => label.trim().toLowerCase()))
: undefined
const requiredLabel = this.#config.safety.requireLabel.trim().toLowerCase()
// Must stay the same scope test as dispatch and as
// #reconcileOrphanedGithubInProgress. Gating on the scope label alone
// left every title-scoped issue stuck in `factory:in-progress` forever.
const mayRecoverGithubOrphan = !wasReady &&
!dryRun &&
issueSource === 'github' &&
Boolean(requiredLabel) &&
Boolean(labels?.has(requiredLabel)) &&
isInFactoryScope(issue, this.#config.safety) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include title-scoped orphans in indexed discovery

When Relayfile provides a valid _index.json, #githubIssuePathsFromIndex filters out every open row that lacks safety.requireLabel (lines 7168-7184), so a title-scoped orphan whose only label is factory:in-progress is never read and this widened recovery gate never executes. The added test omits the index and therefore exercises only the full-tree fallback; indexed production deployments still leave the reported issue stranded. The index filtering must also retain in-progress title-scoped candidates, or fall back to the tree when such candidates cannot be identified from the index.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor title scope when checking recovered readiness

For the newly admitted case—an issue with only a title prefix and factory:in-progress—reconciliation removes the lifecycle label, but #dispatchUnlocked then calls #isIssueReady, whose GitHub branch still returns true only when requireLabel is present (lines 6862-6863). Consequently this sweep cannot redispatch the issue (the new test asserts only the recovery counter/writeback), and after Relayfile reflects the removal, the index filter at lines 7191-7192 excludes the now-ready row because it has neither the required label nor factory:in-progress; the title-scoped issue therefore remains permanently undiscoverable. The readiness/discovery path must continue honoring the same title-or-label scope used here.

Useful? React with 👍 / 👎.

Boolean(labels?.has('factory:in-progress')) &&
!labels?.has('factory:human-review')
if (!mayRecoverGithubOrphan) {
Expand Down Expand Up @@ -3568,10 +3569,14 @@ export class FactoryLoop implements Factory {
if (!context) return { recovered: false, reason: 'orphan-recovery safety context is unavailable' }
if (!isGithubIssue(issue)) return { recovered: false, reason: 'issue is not GitHub-native' }
const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()))
const required = this.#config.safety.requireLabel.trim().toLowerCase()
// Recovery must admit exactly what dispatch admits. Dispatch accepts the
// configured title prefix OR the scope label (`isInFactoryScope`), but this
// gate used to demand the label alone — so an issue admitted by its title
// could be dispatched and then never un-stuck, keeping `factory:in-progress`
// forever once its dispatch died. factory#139 is the live instance: title
// `[factory] ...`, and its only label is `factory:in-progress`.
if (
!required ||
!labels.has(required) ||
!isInFactoryScope(issue, this.#config.safety) ||
!labels.has('factory:in-progress') ||
labels.has('factory:human-review')
) return { recovered: false, reason: 'issue is not an orphan-recovery candidate' }
Expand Down Expand Up @@ -7175,7 +7180,16 @@ export class FactoryLoop implements Factory {
!labels.every((label) => typeof label === 'string')) {
return undefined
}
if (state !== 'open' || !labels.some((label) => label.trim().toLowerCase() === requiredLabel)) {
// Retain Factory's own lifecycle rows even when they lack the scope
// label. The index carries no title, so a title-scoped issue cannot be
// recognised here — and dropping it means its file is never read and the
// orphan-recovery sweep never sees it. `factory:in-progress` is a label
// only Factory applies, so a row carrying it is by definition
// Factory-touched and worth reading; `isInFactoryScope` downstream
// remains the authority on whether anything may be done with it.
const rowLabels = labels.map((label) => label.trim().toLowerCase())
if (state !== 'open' ||
!(rowLabels.includes(requiredLabel) || rowLabels.includes('factory:in-progress'))) {
continue
}
paths.push(`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`)
Expand Down