diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 8dcf790c..f347b1b4 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -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 { diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 29087793..7a221860 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -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) && Boolean(labels?.has('factory:in-progress')) && !labels?.has('factory:human-review') if (!mayRecoverGithubOrphan) { @@ -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' } @@ -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`)