From d97934236a82d4793ba520db1160d3de70e52299 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 18 Jul 2026 14:30:16 +0200 Subject: [PATCH 01/13] fix: restore escalation-to-PR lifecycle --- .../relayfile-github-connection-write.test.ts | 54 ++++++++++++- .../relayfile-github-connection-write.ts | 28 +++++-- src/orchestrator/factory.test.ts | 80 +++++++++++++++++-- src/orchestrator/factory.ts | 61 ++++++++------ 4 files changed, 182 insertions(+), 41 deletions(-) diff --git a/src/mount/relayfile-github-connection-write.test.ts b/src/mount/relayfile-github-connection-write.test.ts index bd93dbc..9d5ba2b 100644 --- a/src/mount/relayfile-github-connection-write.test.ts +++ b/src/mount/relayfile-github-connection-write.test.ts @@ -99,7 +99,7 @@ describe('RelayfileGithubConnectionWrite', () => { expect(git).toHaveBeenNthCalledWith(2, ['-C', '/work/factory', 'rev-parse', 'HEAD']) expect(mount.writes).toEqual([ { - path: '/github/repos/AgentWorkforce/factory/refs/refs%2Fheads%2Ffix%2Fissue-52.json', + path: `/github/repos/AgentWorkforce/factory/refs/${draft}.json`, content: { ref: 'refs/heads/fix/issue-52', sha: '1234567890abcdef1234567890abcdef12345678', @@ -132,7 +132,7 @@ describe('RelayfileGithubConnectionWrite', () => { it('fails closed when the provider does not acknowledge a write', async () => { const mount = new FakeMountClient() - const refPath = '/github/repos/AgentWorkforce/factory/refs/refs%2Fheads%2Ffix%2Fissue-52.json' + const refPath = '/github/repos/AgentWorkforce/factory/refs/factory-fix-issue-52-1234567890ab.json' mount.setConfirmWrite(refPath, 'timeout') const write = new RelayfileGithubConnectionWrite({ mount, gitRunner: gitRunner() }) @@ -145,6 +145,56 @@ describe('RelayfileGithubConnectionWrite', () => { })).rejects.toThrow(`GitHub writeback did not complete for ${refPath}: timeout`) }) + it('updates an existing branch when retrying local-clone publication', async () => { + const draft = 'factory-fix-issue-52-1234567890ab' + const createRefPath = `/github/repos/AgentWorkforce/factory/refs/${draft}.json` + const updateRefPath = '/github/repos/AgentWorkforce/factory/refs/refs%2Fheads%2Ffix%2Fissue-52.json' + const pullRequestPath = `/github/repos/AgentWorkforce/factory/pull-requests/${draft}.json` + class ExistingRefMount extends FakeMountClient { + override async confirmWrite(path: string): Promise<'acked'> { + if (path === createRefPath) { + throw new Error('GitHub writeback failed with status 422: Reference already exists') + } + return 'acked' + } + + override async writeFile(path: string, content: unknown, opts?: { guarded?: boolean }): Promise { + await super.writeFile(path, content, opts) + if (path === pullRequestPath) { + this.files.set(path, { content: { created: 66, url: 'https://github.com/AgentWorkforce/factory/pull/66' } }) + } + } + } + const mount = new ExistingRefMount() + const write = new RelayfileGithubConnectionWrite({ mount, gitRunner: gitRunner() }) + + await expect(write.publishPullRequest({ + repo: 'AgentWorkforce/factory', + clonePath: '/work/factory', + baseRef: 'main', + title: 'Title', + body: 'Body', + })).resolves.toMatchObject({ number: 66 }) + + expect(mount.writes.slice(0, 2)).toEqual([ + { + path: createRefPath, + content: { + ref: 'refs/heads/fix/issue-52', + sha: '1234567890abcdef1234567890abcdef12345678', + }, + }, + { + path: updateRefPath, + content: { + ref: 'refs/heads/fix/issue-52', + sha: '1234567890abcdef1234567890abcdef12345678', + force: false, + }, + }, + ]) + }) + it('rejects publishing from the base branch before writing a ref', async () => { const mount = new FakeMountClient() const write = new RelayfileGithubConnectionWrite({ mount, gitRunner: gitRunnerForBranch('main') }) diff --git a/src/mount/relayfile-github-connection-write.ts b/src/mount/relayfile-github-connection-write.ts index 857dc45..f573f9d 100644 --- a/src/mount/relayfile-github-connection-write.ts +++ b/src/mount/relayfile-github-connection-write.ts @@ -57,15 +57,26 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite { const draftName = githubDraftName(headRef, headSha) const repoRoot = `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` const fullHeadRef = `refs/heads/${headRef}` - const refPath = `${repoRoot}/refs/${encodeURIComponent(fullHeadRef)}.json` + const createRefPath = `${repoRoot}/refs/${draftName}.json` + const updateRefPath = `${repoRoot}/refs/${encodeURIComponent(fullHeadRef)}.json` - // A remote implementer already pushed its branch. Only synthesize/update - // the ref for the legacy local-clone path where the publisher owns HEAD. + // A remote implementer already pushed its branch. For the legacy local-clone + // path, create the branch before opening the PR. Relayfile's canonical encoded + // ref path updates an existing ref and GitHub rejects it for a new branch. if (headSha) { - await this.#writeAndConfirm(refPath, { - ref: fullHeadRef, - sha: headSha, - }) + try { + await this.#writeAndConfirm(createRefPath, { + ref: fullHeadRef, + sha: headSha, + }) + } catch (error) { + if (!isGithubReferenceAlreadyExistsError(error)) throw error + await this.#writeAndConfirm(updateRefPath, { + ref: fullHeadRef, + sha: headSha, + force: false, + }) + } } const pullRequestPath = `${repoRoot}/pull-requests/${draftName}.json` @@ -175,3 +186,6 @@ const nonNegativeInteger = (value: unknown): number | undefined => const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) + +const isGithubReferenceAlreadyExistsError = (error: unknown): boolean => + /reference already exists/iu.test(errorMessage(error)) diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index d108c02..1b42cc3 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -7397,12 +7397,14 @@ describe('FactoryLoop', () => { it('signals and releases a waiting reviewer when an implementer resume collides and no PR exists (#67)', async () => { const mount = new FakeMountClient({ [issuePath(82)]: issueFile(82) }) const fleet = new ResumeNameCollisionFleetClient() + const worktrees = new RecordingWorktreeManager() fleet.setSessionRef('ar-82-impl-pear', 'session-impl-82') const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage(), probePrResolver: async () => undefined, + worktrees, }) const decision = await factory.triageIssue(parseLinearIssue(issuePath(82), issueFile(82))) @@ -7418,7 +7420,8 @@ describe('FactoryLoop', () => { expect(reviewerSignal).toBeDefined() expect(reviewerSignal?.text).toMatch(/terminated/i) // ... and then torn down so the dispatch does not hang. - expect(fleet.releases.map((release) => release.name)).toContain('ar-82-review') + await vi.waitFor(() => expect(worktrees.cleaned).toHaveLength(1)) + expect(fleet.releases.map((release) => release.name).sort()).toEqual(['ar-82-impl-pear', 'ar-82-review']) expect(factory.status().counters.resumeNameCollisions).toBe(1) expect(factory.status().counters.issuesStalledNoPr).toBe(1) expect(factory.status().counters.errors ?? 0).toBe(0) @@ -7699,7 +7702,7 @@ describe('FactoryLoop', () => { }) }) - it('emits an escalation on delivery_failed for an in-flight agent', async () => { + it('ignores an untracked delivery_failed event for an in-flight agent', async () => { const mount = new FakeMountClient({ [issuePath(8)]: issueFile(8) }) const fleet = new FakeFleetClient() const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage() }) @@ -7712,11 +7715,38 @@ describe('FactoryLoop', () => { fleet.emitDeliveryFailed({ to: 'ar-8-review', reason: 'dead-lettered' }) await flush() - expect(errors).toHaveLength(1) - expect(errors[0]).toMatchObject({ issue: { key: 'AR-8' } }) + expect(errors).toEqual([]) + expect(factory.status().counters.nonCriticalDeliveryFailuresIgnored).toBe(1) await factory.stop() }) + it('surfaces a tracked critical delivery failure once without retrying a dead worker', async () => { + const mount = new FakeMountClient({ [issuePath(68)]: issueFile(68) }) + const fleet = new FakeFleetClient() + const stateStore = new InMemoryStateStore({ batchSize: 2 }) + const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage(), stateStore }) + const errors: unknown[] = [] + factory.on('error', (payload) => errors.push(payload)) + const decision = await factory.triageIssue(parseLinearIssue(issuePath(68), issueFile(68))) + + await factory.dispatch(decision) + await stateStore.recordCritical('factory-test', 'critical-68', { + issue: decision.issue, + input: { to: 'ar-68-review', from: 'factory', text: 'Review the completed PR.' }, + }) + fleet.emitDeliveryFailed({ + to: 'ar-68-review', + msgId: 'critical-68', + reason: 'max delivery retries exceeded', + }) + await flush() + + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ issue: { key: 'AR-68' } }) + expect(fleet.messages).toEqual([]) + expect(factory.status().counters.criticalDeliveryTerminalFailures).toBe(1) + }) + it('delivers the complete implementer and reviewer briefings in their spawn tasks', async () => { const mount = new FakeMountClient({ [issuePath(62)]: issueFile(62) }) const fleet = new FakeFleetClient() @@ -7800,8 +7830,8 @@ describe('FactoryLoop', () => { .toContain('Linear issue: AR-63') expect(fleet.messages).toEqual([]) expect(fleet.inputs).toEqual([]) - expect(errors).toHaveLength(1) - expect(errors[0]).toMatchObject({ issue: { key: 'AR-63' } }) + expect(errors).toEqual([]) + expect(factory.status().counters.nonCriticalDeliveryFailuresIgnored).toBe(1) }) it('does not retry an obsolete reviewer handoff injection after delivery_failed', async () => { @@ -9753,7 +9783,11 @@ describe('FactoryLoop', () => { expect(slackRoots).toHaveLength(1) expect((slackRoots[0]?.content as { text?: string }).text).toContain('AR-20: factory triage escalation for [factory-e2e] Fix factory issue 20') expect((slackRoots[0]?.content as { text?: string }).text).toContain('Reason: low-confidence triage and thin issue context: Matched repository from Linear label.') - expect((slackRoots[0]?.content as { text?: string }).text).toContain('Question: Factory matched AgentWorkforce/pear. Please clarify the concrete expected behavior, constraints, and acceptance criteria/tests before dispatch.') + expect((slackRoots[0]?.content as { text?: string }).text).toContain('Question: Factory matched AgentWorkforce/pear.') + expect((slackRoots[0]?.content as { text?: string }).text).toContain('For "[factory-e2e] Fix factory issue 20", please reply with:') + expect((slackRoots[0]?.content as { text?: string }).text).toContain('(1) the exact user flow—where it starts, required inputs/actions, and the successful result;') + expect((slackRoots[0]?.content as { text?: string }).text).toContain('(3) observable acceptance checks or tests.') + expect((slackRoots[0]?.content as { text?: string }).text).toContain('successful work will be opened as a pull request.') expect(factory.status().counters.errors).toBeUndefined() expect(factory.status().counters.triageEscalations).toBe(1) expect(slack.roots).toEqual([]) @@ -9777,6 +9811,9 @@ describe('FactoryLoop', () => { expect(githubWriteback.comments[0]?.body).toContain('Factory needs clarification before dispatching 55.') expect(githubWriteback.comments[0]?.body).toContain('Reason: low-confidence triage and thin issue context: Issue lacks acceptance criteria.') expect(githubWriteback.comments[0]?.body).toContain('Question: Factory matched AgentWorkforce/pear.') + expect(githubWriteback.comments[0]?.body).toContain('For "GitHub factory issue 55", please reply with:') + expect(githubWriteback.comments[0]?.body).toContain('(2) permissions, validation, failure behavior, important edge cases, and anything out of scope;') + expect(githubWriteback.comments[0]?.body).toContain('successful work will be opened as a pull request.') expect(githubWriteback.comments[0]?.body).toMatch(/