diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index 06fbff4..9d85454 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -1494,6 +1494,7 @@ describe('fleet CLI runtime', () => { it('configures guarded GitHub writeback when close-probe creates a cloud mount', async () => { const output = buffer() + const errors = buffer() const closes: Array<{ repo: string; number: number }> = [] const cloudMountFromConfig = vi.fn(async (opts) => { const mount = new FakeMountClient() @@ -1503,6 +1504,26 @@ describe('fleet CLI runtime', () => { const path = `/github/repos/${input.repo}/pulls/${input.number}/close.json` const allowed = await opts?.isAllowedDraft?.(path, {}, { guarded: true }) if (!allowed) throw new Error('GitHub close draft rejected by mount predicate') + expect(await opts?.isAllowedDraft?.( + `/github/repos/${input.repo}/refs/factory.json`, + { ref: 'refs/heads/factory/77', sha: 'abc123' }, + { guarded: true }, + )).toBe(true) + expect(await opts?.isAllowedDraft?.( + `/github/repos/${input.repo}/refs/refs%2Fheads%2Ffactory%2F77.json`, + { ref: 'refs/heads/factory/77', sha: 'abc123' }, + { guarded: true }, + )).toBe(true) + expect(await opts?.isAllowedDraft?.( + `/github/repos/${input.repo}/refs/refs%2Fheads%2Fmain.json`, + { ref: 'refs/heads/main', sha: 'abc123' }, + { guarded: true }, + )).toBe(false) + expect(await opts?.isAllowedDraft?.( + `/github/repos/${input.repo}/refs/arbitrary.json`, + { ref: 'refs/heads/factory/77', sha: 'abc123' }, + { guarded: true }, + )).toBe(false) closes.push(input) }, } @@ -1519,7 +1540,7 @@ describe('fleet CLI runtime', () => { 'AR-77', ], { stdout: output, - stderr: buffer(), + stderr: errors, resolveWorkspace: async () => ({ workspaceId: 'rw_test' }), cloudMountFromConfig, probePrGhRunner: async () => ({ @@ -1532,7 +1553,7 @@ describe('fleet CLI runtime', () => { }), }) - expect(code).toBe(0) + expect(code, errors.text()).toBe(0) expect(cloudMountFromConfig).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'rw_test', isAllowedDraft: expect.any(Function), diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index 7c2626b..60b6d03 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -990,7 +990,7 @@ async function isAllowedFactoryDraft( } const isFactoryGithubWritebackPath = (path: string): boolean => - /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/refs%2Fheads%2F[^/]+\.json|pulls\/[1-9]\d*\/close\.json)$/iu.test(path) + /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/(?:factory\.json|refs%2Fheads%2Ffactory%2F[^/]+\.json)|pulls\/[1-9]\d*\/close\.json)$/iu.test(path) const isAllowedFactoryGithubDraft = ( path: string, diff --git a/src/issue-key-match.test.ts b/src/issue-key-match.test.ts index fc3b0a1..7d24d61 100644 --- a/src/issue-key-match.test.ts +++ b/src/issue-key-match.test.ts @@ -16,4 +16,11 @@ describe('issue key matching', () => { expect(containsExplicitIssueReference('Closes AR-229', 'AR-229')).toBe(true) expect(containsExplicitIssueReference('This merely mentions ar-229 in passing.', 'AR-229')).toBe(false) }) + + it('does not treat numeric test counts as GitHub issue references', () => { + expect(containsExplicitIssueReference('tsc, eslint, and 52 tests all pass.', '52')).toBe(false) + expect(containsExplicitIssueReference('Fixes #52', '52')).toBe(true) + expect(containsExplicitIssueReference('Issue: 52', '52')).toBe(true) + expect(containsExplicitIssueReference('https://github.com/AgentWorkforce/hoopsheet/issues/52', '52')).toBe(true) + }) }) diff --git a/src/issue-key-match.ts b/src/issue-key-match.ts index f804b73..73b508c 100644 --- a/src/issue-key-match.ts +++ b/src/issue-key-match.ts @@ -16,7 +16,21 @@ export const containsIssueKey = (value: string, issueKey: string): boolean => { export const containsExplicitIssueReference = (value: string, issueKey: string): boolean => { const parts = ISSUE_KEY_PARTS.exec(issueKey) - if (!parts) return containsIssueKey(value, issueKey) + if (!parts) { + if (/^\d+$/u.test(issueKey)) { + const number = escapeRegex(issueKey) + return new RegExp( + [ + `#${number}(?!\\d)`, + `https?://github\\.com/[^\\s/]+/[^\\s/]+/issues/${number}(?!\\d)`, + `(?:^|\\n)\\s*(?:github\\s+)?issue\\s*:?[ \\t]*#?${number}(?!\\d)`, + `(?:^|\\n)\\s*(?:closes|fixes|resolves)\\s*:?[ \\t]*#?${number}(?!\\d)`, + ].join('|'), + 'i', + ).test(value) + } + return containsIssueKey(value, issueKey) + } const prefix = escapeRegex(parts[1] ?? '') const number = escapeRegex(parts[2] ?? '') diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index 883b71c..d2bd2b1 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -11,6 +11,7 @@ import { type RelayfileCloudMountClientConfig, type RelayFileClientLike, } from './relayfile-cloud-mount-client' +import { RelayfileGithubConnectionWrite } from './relayfile-github-connection-write' const storedAuth = (overrides: Partial = {}): StoredAuth => ({ apiUrl: 'https://cloud.example', @@ -1066,6 +1067,51 @@ describe('RelayfileCloudMountClient', () => { await expect(mount.confirmWrite('/linear/issues/new.json', { timeoutMs: 5 })) .rejects.toThrow(/Field "id" is read-only/) + await expect(mount.getConfirmedWriteFailureReason('/linear/issues/new.json')) + .resolves.toBe('Field "id" is read-only and cannot be written') + }) + + it('feeds an existing-reference provider failure into the GitHub update-ref fallback', async () => { + const fake = new FakeRelayFileClient() + fake.ops.set('op-1', { + opId: 'op-1', + status: 'failed', + attemptCount: 1, + lastError: 'GitHub writeback failed with status 422: Reference already exists', + }) + fake.ops.set('op-2', { + opId: 'op-2', + status: 'succeeded', + attemptCount: 1, + providerResult: { status: 200, externalId: 'factory/test-existing-ref' }, + }) + fake.ops.set('op-3', { + opId: 'op-3', + status: 'succeeded', + attemptCount: 1, + providerResult: { status: 201, externalId: '66' }, + }) + const mount = new RelayfileCloudMountClient({ + workspaceId: 'rw_test', + client: fake, + isAllowedDraft: () => true, + }) + const writer = new RelayfileGithubConnectionWrite({ mount }) + + await expect(writer.publishPullRequest({ + repo: 'AgentWorkforce/factory', + headRef: 'factory/test-existing-ref', + headSha: '1234567890abcdef1234567890abcdef12345678', + baseRef: 'main', + title: 'Title', + body: 'Body', + })).resolves.toMatchObject({ number: 66 }) + + expect(fake.writeFileCalls.map((call) => call.path)).toEqual([ + '/github/repos/AgentWorkforce/factory/refs/factory.json', + '/github/repos/AgentWorkforce/factory/refs/refs%2Fheads%2Ffactory%2Ftest-existing-ref.json', + expect.stringMatching(/\/github\/repos\/AgentWorkforce\/factory\/pull-requests\/factory-/u), + ]) }) it('delegates subscribe through the workspace-scoped event client', () => { diff --git a/src/mount/relayfile-cloud-mount-client.ts b/src/mount/relayfile-cloud-mount-client.ts index 899845d..9f5a6bc 100644 --- a/src/mount/relayfile-cloud-mount-client.ts +++ b/src/mount/relayfile-cloud-mount-client.ts @@ -184,6 +184,7 @@ export class RelayfileCloudMountClient implements MountClient { readonly #isAllowedDelete?: (path: string, currentContent: unknown) => boolean | Promise readonly #lastOpByPath = new Map() readonly #confirmedExternalIdByPath = new Map() + readonly #confirmedFailureReasonByPath = new Map() constructor(config: RelayfileCloudMountClientConfig = {}) { if (!config.client) { @@ -301,6 +302,7 @@ export class RelayfileCloudMountClient implements MountClient { const serialized = serializeContent(content) this.#confirmedExternalIdByPath.delete(path) + this.#confirmedFailureReasonByPath.delete(path) const writeAtCurrentRevision = async (): Promise => { let baseRevision = '0' @@ -329,6 +331,7 @@ export class RelayfileCloudMountClient implements MountClient { async deleteFile(path: string): Promise { this.#confirmedExternalIdByPath.delete(path) + this.#confirmedFailureReasonByPath.delete(path) const current = await this.#client.readFile(this.workspaceId, path) const currentContent = parseRemoteContent(current) if (isProviderPath(path)) { @@ -440,7 +443,13 @@ export class RelayfileCloudMountClient implements MountClient { const deadline = Date.now() + (opts.timeoutMs ?? 90_000) for (;;) { const operation = await this.#client.getOp(this.workspaceId, opId) - const status = mapOperationStatus(operation) + let status: 'acked' | 'pending' | 'failed' + try { + status = mapOperationStatus(operation) + } catch (error) { + this.#confirmedFailureReasonByPath.set(path, providerResultError(operation)) + throw error + } if (status !== 'pending') { const providerId = [ operation.providerResult?.externalId, @@ -457,6 +466,10 @@ export class RelayfileCloudMountClient implements MountClient { } } + async getConfirmedWriteFailureReason(path: string): Promise { + return this.#confirmedFailureReasonByPath.get(path) + } + async getConfirmedWriteExternalId(path: string): Promise { return this.#confirmedExternalIdByPath.get(path) } diff --git a/src/mount/relayfile-github-connection-write.test.ts b/src/mount/relayfile-github-connection-write.test.ts index bd93dbc..32f55f9 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/factory.json', content: { ref: 'refs/heads/fix/issue-52', sha: '1234567890abcdef1234567890abcdef12345678', @@ -118,6 +118,48 @@ describe('RelayfileGithubConnectionWrite', () => { ]) }) + it('serializes concurrent create-ref confirmations that share the repository draft path', async () => { + const createRefPath = '/github/repos/AgentWorkforce/factory/refs/factory.json' + let releaseFirst!: () => void + const firstConfirmation = new Promise((resolve) => { releaseFirst = resolve }) + class ConcurrentRefMount extends FakeMountClient { + createConfirmations = 0 + + override async confirmWrite(path: string): Promise<'acked'> { + if (path === createRefPath && ++this.createConfirmations === 1) await firstConfirmation + return 'acked' + } + + async getConfirmedWriteExternalId(path: string): Promise { + if (!path.includes('/pull-requests/')) return undefined + return path.includes('concurrent-one') ? '71' : '72' + } + } + const mount = new ConcurrentRefMount() + const write = new RelayfileGithubConnectionWrite({ mount }) + const publish = (headRef: string, headSha: string) => write.publishPullRequest({ + repo: 'AgentWorkforce/factory', + headRef, + headSha, + baseRef: 'main', + title: headRef, + body: 'Concurrent publication proof', + }) + + const first = publish('factory/concurrent-one', '1111111111111111111111111111111111111111') + await vi.waitFor(() => expect(mount.createConfirmations).toBe(1)) + const second = publish('factory/concurrent-two', '2222222222222222222222222222222222222222') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mount.writes.filter((entry) => entry.path === createRefPath)).toHaveLength(1) + + releaseFirst() + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { number: 71 }, + { number: 72 }, + ]) + expect(mount.writes.filter((entry) => entry.path === createRefPath)).toHaveLength(2) + }) + it('closes a pull request through its exact Relayfile writeback path', async () => { const mount = new FakeMountClient() const write = new RelayfileGithubConnectionWrite({ mount, gitRunner: gitRunner() }) @@ -132,7 +174,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.json' mount.setConfirmWrite(refPath, 'timeout') const write = new RelayfileGithubConnectionWrite({ mount, gitRunner: gitRunner() }) @@ -145,6 +187,89 @@ 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/factory.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' | 'failed'> { + return path === createRefPath ? 'failed' : 'acked' + } + + async getConfirmedWriteFailureReason(path: string): Promise { + return path === createRefPath + ? 'GitHub writeback failed with status 422: Reference already exists' + : undefined + } + + 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('recognizes an existing-reference failure from a plain provider error object', async () => { + const createRefPath = '/github/repos/AgentWorkforce/factory/refs/factory.json' + const pullRequestPath = '/github/repos/AgentWorkforce/factory/pull-requests/factory-fix-issue-52-1234567890ab.json' + class PlainErrorMount extends FakeMountClient { + override async confirmWrite(path: string): Promise<'acked'> { + if (path === createRefPath) { + throw { message: '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: 67, url: 'https://github.com/AgentWorkforce/factory/pull/67' } }) + } + } + } + const mount = new PlainErrorMount() + 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: 67 }) + }) + it('rejects publishing from the base branch before writing a ref', async () => { const mount = new FakeMountClient() const write = new RelayfileGithubConnectionWrite({ mount, gitRunner: gitRunnerForBranch('main') }) @@ -197,4 +322,37 @@ describe('RelayfileGithubConnectionWrite', () => { })).resolves.toMatchObject({ number: 65 }) expect(mount.receiptReads).toBe(2) }) + + it('uses the acknowledged provider id when Relayfile reconciles the create draft', async () => { + const draft = 'factory-fix-issue-52-1234567890ab' + const pullRequestPath = `/github/repos/AgentWorkforce/hoopsheet/pull-requests/${draft}.json` + class ReconciledDraftMount extends FakeMountClient { + override async readFile(path: string): Promise<{ content: unknown; revision?: string }> { + if (path === pullRequestPath) throw new Error('draft was reconciled after provider acknowledgement') + return super.readFile(path) + } + + override async getConfirmedWriteExternalId(path: string): Promise { + return path === pullRequestPath ? '53' : undefined + } + } + const mount = new ReconciledDraftMount() + const write = new RelayfileGithubConnectionWrite({ + mount, + gitRunner: gitRunner(), + receiptReadAttempts: 1, + receiptReadDelayMs: 0, + }) + + await expect(write.publishPullRequest({ + repo: 'AgentWorkforce/hoopsheet', + clonePath: '/work/hoopsheet', + baseRef: 'main', + title: '52: Bug: Creating a new league does not work', + body: 'Fixes #52', + })).resolves.toMatchObject({ + number: 53, + url: 'https://github.com/AgentWorkforce/hoopsheet/pull/53', + }) + }) }) diff --git a/src/mount/relayfile-github-connection-write.ts b/src/mount/relayfile-github-connection-write.ts index 857dc45..b60715f 100644 --- a/src/mount/relayfile-github-connection-write.ts +++ b/src/mount/relayfile-github-connection-write.ts @@ -16,7 +16,7 @@ const RECEIPT_READ_DELAY_MS = 100 export type GitCommandRunner = (args: string[]) => Promise<{ stdout: string; stderr?: string }> export interface RelayfileGithubConnectionWriteConfig { - mount: Pick + mount: Pick gitRunner?: GitCommandRunner receiptReadAttempts?: number receiptReadDelayMs?: number @@ -32,6 +32,7 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite { readonly #git: GitCommandRunner readonly #receiptReadAttempts: number readonly #receiptReadDelayMs: number + readonly #writesByPath = new Map>() constructor(config: RelayfileGithubConnectionWriteConfig) { this.#mount = config.mount @@ -57,19 +58,33 @@ 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` - - // A remote implementer already pushed its branch. Only synthesize/update - // the ref for the legacy local-clone path where the publisher owns HEAD. + // Relayfile's GitHub adapter exposes one guarded draft endpoint for create + // and a canonical encoded endpoint for update. The draft filename is an + // adapter contract; the requested branch identity lives in the payload. + const createRefPath = `${repoRoot}/refs/factory.json` + const updateRefPath = `${repoRoot}/refs/${encodeURIComponent(fullHeadRef)}.json` + + // 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` - await this.#writeAndConfirm(pullRequestPath, { + const confirmedPullRequestId = await this.#writeAndConfirm(pullRequestPath, { title: input.title, head: headRef, base: input.baseRef, @@ -79,7 +94,11 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite { author: 'app', }) - const { number, url } = await this.#readPullRequestReceipt(pullRequestPath, input.repo) + const { number, url } = await this.#readPullRequestReceipt( + pullRequestPath, + input.repo, + confirmedPullRequestId, + ) return { repo: input.repo, @@ -111,7 +130,22 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite { throw new Error(`Unable to resolve ${description} for GitHub PR publication`) } - async #readPullRequestReceipt(path: string, repo: string): Promise<{ number: number; url: string }> { + async #readPullRequestReceipt( + path: string, + repo: string, + confirmedExternalId?: string, + ): Promise<{ number: number; url: string }> { + // The cloud mount confirms provider success from the durable operation and + // retains its externalId. Relayfile may immediately reconcile (rename or + // remove) the authored draft, so reading that draft is not a reliable way + // to recover the receipt after an acknowledged create. + const confirmedNumber = positiveInteger(confirmedExternalId) + if (confirmedNumber) { + return { + number: confirmedNumber, + url: `https://github.com/${repo}/pull/${confirmedNumber}`, + } + } for (let attempt = 0; attempt < this.#receiptReadAttempts; attempt += 1) { try { const receipt = record((await this.#mount.readFile(path)).content) @@ -128,12 +162,29 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite { throw new Error(`GitHub pull request writeback returned an incomplete receipt for ${repo}`) } - async #writeAndConfirm(path: string, content: unknown): Promise { + async #writeAndConfirm(path: string, content: unknown): Promise { + const previous = this.#writesByPath.get(path) ?? Promise.resolve(undefined) + const current = previous + .catch(() => undefined) + .then(async () => await this.#writeAndConfirmUnlocked(path, content)) + this.#writesByPath.set(path, current) + try { + return await current + } finally { + if (this.#writesByPath.get(path) === current) this.#writesByPath.delete(path) + } + } + + async #writeAndConfirmUnlocked(path: string, content: unknown): Promise { await this.#mount.writeFile(path, content, { guarded: true }) const status = await this.#mount.confirmWrite(path, { timeoutMs: WRITE_CONFIRM_TIMEOUT_MS }) if (status !== 'acked') { - throw new Error(`GitHub writeback did not complete for ${path}: ${status}`) + const failureReason = status === 'failed' + ? await this.#mount.getConfirmedWriteFailureReason?.(path) + : undefined + throw new Error(`GitHub writeback did not complete for ${path}: ${failureReason ?? status}`) } + return this.#mount.getConfirmedWriteExternalId?.(path) } } @@ -174,4 +225,13 @@ 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 errorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message + if (error !== null && typeof error === 'object' && 'message' in error) { + return String((error as { message?: unknown }).message) + } + return 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..2f15c87 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -135,6 +135,7 @@ const githubIssueFile = ( labels?: Array url?: string author?: string + updatedAt?: string } = {}, ) => { const owner = payload.owner ?? 'AgentWorkforce' @@ -148,6 +149,7 @@ const githubIssueFile = ( title: payload.title ?? `GitHub factory issue ${number}`, body: payload.body ?? 'Implement the requested GitHub issue change and verify it with tests.', state: payload.state ?? 'open', + updated_at: payload.updatedAt, labels: payload.labels ?? [{ name: 'factory' }], url: payload.url ?? `https://github.com/${owner}/${repo}/issues/${number}`, author: { login: payload.author ?? 'issue-author' }, @@ -1619,7 +1621,7 @@ describe('FactoryLoop', () => { it('dispatches a labeled GitHub issue without Linear and writes in-progress then human-review to GitHub', async () => { const path = githubIssuePath('AgentWorkforce', 'pear', 48) const mount = new FakeMountClient({ - [path]: githubIssueFile(48, { labels: ['factory'] }), + [path]: githubIssueFile(48, { labels: ['factory', 'bug'] }), }) mount.setSubRoot('/linear/issues', 'absent') const fleet = new FakeFleetClient() @@ -1901,24 +1903,165 @@ describe('FactoryLoop', () => { } }) - it('preserves an in-progress GitHub issue when a matching open PR exists', async () => { + it('prioritizes fresh GitHub-ready work ahead of orphan recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-ready-before-orphans-')) + try { + const orphanPath = githubIssuePath('AgentWorkforce', 'pear', 11) + const freshPath = githubIssuePath('AgentWorkforce', 'pear', 52) + const mount = new FakeMountClient({ + [orphanPath]: githubIssueFile(11, { labels: ['factory', 'pear', 'factory:in-progress'] }), + [freshPath]: githubIssueFile(52, { labels: ['factory', 'pear'] }), + }) + const fleet = new FakeFleetClient() + const githubWriteback = new RecordingGithubWriteback() + const factory = createFactory(config({ + issueSource: 'github', + batchSize: 1, + loop: { registryPath: join(root, 'registry.json') }, + }), { + mount, + fleet, + triage: new StaticTriage(), + githubWriteback, + probePrGhRunner: async () => ({ stdout: '[]' }), + }) + + const report = await factory.runOnce() + + expect(report.dispatched.map((result) => result.issue.key)).toEqual(['52']) + expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-52-impl-pear', 'ar-52-review-pear']) + expect(factory.status().queued.map((issue) => issue.key)).toEqual(['11']) + expect(githubWriteback.statuses[0]).toEqual({ key: '52', status: 'in-progress' }) + expect(githubWriteback.statuses).toContainEqual({ key: '11', status: 'ready' }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('reconciles the most recently updated GitHub orphan before an older numeric backlog', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-newest-orphan-first-')) + try { + const olderPath = githubIssuePath('AgentWorkforce', 'pear', 11) + const interruptedPath = githubIssuePath('AgentWorkforce', 'pear', 52) + const mount = new FakeMountClient({ + [olderPath]: githubIssueFile(11, { + labels: ['factory', 'pear', 'factory:in-progress'], + updatedAt: '2026-07-17T12:00:00.000Z', + }), + [interruptedPath]: githubIssueFile(52, { + labels: ['factory', 'pear', 'factory:in-progress'], + updatedAt: '2026-07-18T12:00:00.000Z', + }), + }) + const fleet = new FakeFleetClient() + const factory = createFactory(config({ + issueSource: 'github', + batchSize: 1, + loop: { registryPath: join(root, 'registry.json') }, + }), { + mount, + fleet, + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + probePrGhRunner: async () => ({ stdout: '[]' }), + }) + + const report = await factory.runOnce() + + expect(report.dispatched.map((result) => result.issue.key)).toEqual(['52']) + expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-52-impl-pear', 'ar-52-review-pear']) + expect(factory.status().queued.map((issue) => issue.key)).toEqual(['11']) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it.each([true, false])( + 'continues startup reconciliation when an issue changes state during dispatch (worktrees: %s)', + async (withWorktrees) => { + const racedPath = githubIssuePath('AgentWorkforce', 'pear', 59) + const freshPath = githubIssuePath('AgentWorkforce', 'pear', 60) + const racedReady = githubIssueFile(59, { labels: ['factory', 'pear'] }) + class ConcurrentStateChangeMount extends FakeMountClient { + racedReads = 0 + + override async readFile(path: string): Promise<{ content: unknown; revision?: string }> { + if (path === racedPath) { + this.racedReads += 1 + if (this.racedReads >= 3) { + return { + content: githubIssueFile(59, { labels: ['factory', 'pear', 'factory:in-progress'] }), + } + } + } + return super.readFile(path) + } + } + const mount = new ConcurrentStateChangeMount({ + [racedPath]: racedReady, + [freshPath]: githubIssueFile(60, { labels: ['factory', 'pear'] }), + }) + const fleet = new FakeFleetClient() + const worktrees = new RecordingWorktreeManager() + const factory = createFactory(config({ issueSource: 'github', batchSize: 4 }), { + mount, + fleet, + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + ...(withWorktrees ? { worktrees } : {}), + }) + + const report = await factory.runOnce() + + expect(report.skipped).toContainEqual({ + issue: { uuid: 'AgentWorkforce/pear#59', key: '59', path: racedPath }, + reason: 'live state changed during dispatch', + }) + expect(report.dispatched.map((result) => result.issue.key)).toEqual(['60']) + expect(fleet.spawns.map((spawn) => spawn.name)).toEqual([ + 'ar-59-impl-pear', + 'ar-59-review-pear', + 'ar-60-impl-pear', + 'ar-60-review-pear', + ]) + expect(fleet.releases.map((release) => release.name).sort()).toEqual([ + 'ar-59-impl-pear', + 'ar-59-review-pear', + ]) + expect(factory.status().counters.dispatchLiveStateRaces).toBe(1) + expect(factory.status().counters.errors ?? 0).toBe(0) + }, + ) + + it('adopts an orphaned in-progress GitHub issue when its ready PR already exists', async () => { const root = await mkdtemp(join(tmpdir(), 'factory-orphan-open-pr-')) try { const path = githubIssuePath('AgentWorkforce', 'pear', 53) const mount = new FakeMountClient({ [path]: githubIssueFile(53, { labels: ['factory', 'pear', 'factory:in-progress'] }), }) - const fleet = new FakeFleetClient() + const fleet = new RemoteLifecycleFleetClient() + const worktrees = new RecordingWorktreeManager() const githubWriteback = new RecordingGithubWriteback() + const stateStore = new InMemoryStateStore({ batchSize: 2 }) const factory = createFactory(config({ issueSource: 'github', + babysitter: { enabled: true }, + terminalState: 'human-review', loop: { registryPath: join(root, 'registry.json') }, }), { mount, fleet, triage: new StaticTriage(), githubWriteback, - probePrResolver: async () => ({ repo: 'AgentWorkforce/pear', prNumber: 153 }), + stateStore, + worktrees, + probePrResolver: async () => ({ + repo: 'AgentWorkforce/pear', + prNumber: 153, + headRef: 'factory/53-agentworkforce-pear-proof', + url: 'https://github.com/AgentWorkforce/pear/pull/153', + }), }) const report = await factory.runOnce() @@ -1928,14 +2071,135 @@ describe('FactoryLoop', () => { issue: { uuid: 'AgentWorkforce/pear#53', key: '53', path }, reason: 'live state is not ready-for-agent', }]) - expect(fleet.spawns).toEqual([]) + expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-53-babysit-pear']) + expect(fleet.spawns[0]).toMatchObject({ + repo: 'AgentWorkforce/pear', + cwd: expect.stringMatching(/\/\.factory-worktrees\/pear\/53-pear-[a-z0-9]+$/u), + }) + expect(fleet.spawns[0]?.task) + .toContain('Continue in the existing isolated issue worktree on branch `factory/53-agentworkforce-pear-proof`.') + expect(worktrees.prepared).toEqual([expect.objectContaining({ + issueKey: '53', + branch: 'factory/53-agentworkforce-pear-proof', + })]) expect(githubWriteback.statuses).toEqual([]) expect(factory.status().counters.githubOrphanRecoveriesBlockedOpenPr).toBe(1) + expect(factory.status().counters.githubOrphanedPullRequestsAdopted).toBe(1) + expect(factory.status().inFlight.map((issue) => issue.key)).toEqual(['53']) + expect(await stateStore.getDispatchLifecycle('factory-test', issueKey({ + uuid: 'AgentWorkforce/pear#53', + key: '53', + path, + }))).toMatchObject({ + phase: 'running', + pullRequest: { + repo: 'AgentWorkforce/pear', + number: 153, + headRef: 'factory/53-agentworkforce-pear-proof', + }, + }) } finally { await rm(root, { recursive: true, force: true }) } }) + it('queues an orphaned open PR and promotes it directly to a babysitter when capacity opens', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-orphan-open-pr-queued-')) + const clock = new ManualClock() + const stateStore = new InMemoryStateStore({ batchSize: 1 }) + const runningPath = githubIssuePath('AgentWorkforce', 'pear', 54) + const adoptedPath = githubIssuePath('AgentWorkforce', 'pear', 53) + const mount = new FakeMountClient({ + [runningPath]: githubIssueFile(54, { + labels: ['factory', 'pear'], + updatedAt: '2026-07-18T12:00:00.000Z', + }), + [adoptedPath]: githubIssueFile(53, { + labels: ['factory', 'pear', 'factory:in-progress'], + updatedAt: '2026-07-18T11:00:00.000Z', + }), + }) + const firstFleet = new RemoteLifecycleFleetClient() + const worktrees = new RecordingWorktreeManager() + const githubWriteback = new RecordingGithubWriteback() + const factoryConfig = config({ + issueSource: 'github', + batchSize: 1, + babysitter: { enabled: true }, + terminalState: 'human-review', + loop: { registryPath: join(root, 'registry.json') }, + }) + const dependencies = { + mount, + stateStore, + triage: new StaticTriage(), + githubWriteback, + worktrees, + clock, + probePrResolver: async (issue: LinearIssue) => issue.key === '53' + ? { + repo: 'AgentWorkforce/pear', + prNumber: 153, + headRef: 'factory/53-agentworkforce-pear-proof', + url: 'https://github.com/AgentWorkforce/pear/pull/153', + } + : undefined, + } + const first = createFactory(factoryConfig, { ...dependencies, fleet: firstFleet }) + let restarted: ReturnType | undefined + try { + await first.runOnce() + + expect(firstFleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-54-impl-pear', 'ar-54-review-pear']) + const adoptedRef = { uuid: 'AgentWorkforce/pear#53', key: '53', path: adoptedPath } + expect(await stateStore.getDispatchLifecycle('factory-test', issueKey(adoptedRef))).toMatchObject({ + phase: 'queued', + pullRequest: { + repo: 'AgentWorkforce/pear', + number: 153, + headRef: 'factory/53-agentworkforce-pear-proof', + }, + }) + expect(first.status().counters.githubOrphanedPullRequestsAdopted).toBe(1) + + await first.stop() + clock.advance(5 * 60_000) + const runningRef = { uuid: 'AgentWorkforce/pear#54', key: '54', path: runningPath } + const runningKey = issueKey(runningRef) + const running = await stateStore.getDispatchLifecycle('factory-test', runningKey) + expect(running).toBeDefined() + const completion = await stateStore.claimDispatchLifecycle( + 'factory-test', + runningKey, + running!, + 'test-completer', + clock.now(), + 60_000, + ) + expect(completion.acquired).toBe(true) + expect(await stateStore.saveDispatchLifecycle( + 'factory-test', + runningKey, + 'test-completer', + completion.lease!.epoch, + clock.now(), + { ...completion.lifecycle, phase: 'complete', updatedAtMs: clock.now() }, + )).toBe(true) + + const restartedFleet = new RemoteLifecycleFleetClient() + restarted = createFactory(factoryConfig, { ...dependencies, fleet: restartedFleet }) + await restarted.start({ mode: 'dispatch-owner' }) + await vi.waitFor(() => expect(restartedFleet.spawns.map((spawn) => spawn.name)) + .toEqual(['ar-53-babysit-pear']), { timeout: 4_000 }) + expect(restartedFleet.spawns.some((spawn) => spawn.name.includes('-impl-') || spawn.name.includes('-review-'))) + .toBe(false) + } finally { + await restarted?.stop() + await first.stop() + await rm(root, { recursive: true, force: true }) + } + }) + it('preserves an in-progress GitHub issue while its registered agent is still online', async () => { const root = await mkdtemp(join(tmpdir(), 'factory-orphan-active-agent-')) try { @@ -4009,6 +4273,111 @@ describe('FactoryLoop', () => { } }) + it('reconciles the exact existing branch after PR creation succeeded before receipt persistence', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-publish-reconcile-existing-')) + const watchStatePath = join(root, 'state.json') + let publishAttempts = 0 + let branch: string | undefined + const githubWrite: GithubConnectionWrite = { + publishPullRequest: async (input) => { + publishAttempts += 1 + branch = input.headRef + mount.files.set('/github/repos/AgentWorkforce/pear/pulls/1186/metadata.json', { content: { + number: 1186, + state: 'open', + draft: false, + head_ref: branch, + url: 'https://github.com/AgentWorkforce/pear/pull/1186', + } }) + throw new Error('receipt was lost after the provider created the PR') + }, + closePullRequest: async () => undefined, + } + const mount = new FakeMountClient({ + [issuePath(186)]: issueFile(186), + '/github/repos/AgentWorkforce/pear/meta.json': { default_branch: 'main' }, + }, githubWrite) + const fleet = new RemoteLifecycleFleetClient() + const stateStore = new FileStateStore({ batchSize: 2, watchStatePath }) + const factory = createFactory(config(), { + mount, + fleet, + stateStore, + triage: new StaticTriage(), + probePrResolver: async () => undefined, + }) + try { + const decision = await factory.triageIssue(parseLinearIssue(issuePath(186), issueFile(186))) + await factory.dispatch(decision) + const lifecycle = await stateStore.getDispatchLifecycle('factory-test', issueKey(decision.issue)) + expect(lifecycle?.decision.implementers[0]?.branch) + .toEqual(expect.stringMatching(/^factory\/ar-186-agentworkforce-pear-[0-9a-f]{8}$/u)) + + fleet.emitAgentExit('ar-186-impl-pear', 'exited') + + await vi.waitFor(() => expect(factory.status().counters.done).toBe(1), { timeout: 4_000 }) + expect(branch).toBe(lifecycle?.decision.implementers[0]?.branch) + expect(publishAttempts).toBe(1) + expect(factory.status().counters.githubPullRequestsReconciled).toBe(1) + expect(fleet.resumes).toEqual([]) + expect(fleet.releases.map((release) => release.name).sort()).toEqual(['ar-186-impl-pear', 'ar-186-review']) + } finally { + await factory.stop() + await rm(root, { recursive: true, force: true }) + } + }) + + it('does not reconcile an existing branch when its PR draft state is unknown', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-publish-reject-unknown-draft-')) + const watchStatePath = join(root, 'state.json') + let publishAttempts = 0 + const githubWrite: GithubConnectionWrite = { + publishPullRequest: async (input) => { + publishAttempts += 1 + if (publishAttempts === 1) { + mount.files.set('/github/repos/AgentWorkforce/pear/pulls/1187/metadata.json', { content: { + number: 1187, + state: 'open', + head_ref: input.headRef, + url: 'https://github.com/AgentWorkforce/pear/pull/1187', + } }) + throw new Error('receipt was lost after the provider created a PR with unknown draft state') + } + return { + repo: input.repo, + number: 1188, + url: 'https://github.com/AgentWorkforce/pear/pull/1188', + headRef: input.headRef!, + } + }, + closePullRequest: async () => undefined, + } + const mount = new FakeMountClient({ + [issuePath(187)]: issueFile(187), + '/github/repos/AgentWorkforce/pear/meta.json': { default_branch: 'main' }, + }, githubWrite) + const fleet = new RemoteLifecycleFleetClient() + const factory = createFactory(config(), { + mount, + fleet, + stateStore: new FileStateStore({ batchSize: 2, watchStatePath }), + triage: new StaticTriage(), + probePrResolver: async () => undefined, + }) + try { + const decision = await factory.triageIssue(parseLinearIssue(issuePath(187), issueFile(187))) + await factory.dispatch(decision) + fleet.emitAgentExit('ar-187-impl-pear', 'exited') + + await vi.waitFor(() => expect(factory.status().counters.done).toBe(1), { timeout: 4_000 }) + expect(publishAttempts).toBe(2) + expect(factory.status().counters.githubPullRequestsReconciled).toBeUndefined() + } finally { + await factory.stop() + await rm(root, { recursive: true, force: true }) + } + }) + it('reuses the provider receipt after losing the lifecycle lease between publication and persistence', async () => { class RejectFirstPublishedSaveStore extends FileStateStore { rejected = false @@ -7397,12 +7766,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,13 +7789,101 @@ 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) expect(factory.status().inFlight).toEqual([]) }) + it('releases non-worktree agents while tearing down a mixed abandoned dispatch', async () => { + const path = issuePath(820) + const issue = realIssueFile(820, ready, { + labels: [{ name: 'pear' }, { name: 'remote' }, { name: 'factory:team' }], + }) + const mount = new FakeMountClient({ [path]: issue }) + const fleet = new ResumeNameCollisionFleetClient() + const worktrees = new RecordingWorktreeManager() + fleet.setSessionRef('ar-820-impl-pear', 'session-impl-820') + const factory = createFactory(config({ + repos: { + byLabel: { + pear: 'AgentWorkforce/pear', + remote: 'AgentWorkforce/remote', + }, + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'AgentWorkforce/pear', + }, + }), { + mount, + fleet, + triage: new StaticTriage(), + probePrResolver: async () => undefined, + worktrees, + }) + const decision = await factory.triageIssue(parseLinearIssue(path, issue)) + + await factory.dispatch(decision) + fleet.emitAgentExit('ar-820-impl-pear', 'crash') + + await vi.waitFor(() => expect(worktrees.cleaned).toHaveLength(1)) + expect(fleet.releases.map((release) => release.name).sort()).toEqual([ + 'ar-820-impl-pear', + 'ar-820-impl-remote', + 'ar-820-review', + ]) + expect(factory.status().inFlight).toEqual([]) + }) + + it('retains mixed abandoned-dispatch ownership until a failed agent release succeeds', async () => { + class FailOnceReleaseFleet extends ResumeNameCollisionFleetClient { + failed = false + + override async release(name: string, reason?: string): Promise { + if (name === 'ar-821-impl-remote' && !this.failed) { + this.failed = true + throw new Error('transient remote release failure') + } + await super.release(name, reason) + } + } + + const path = issuePath(821) + const issue = realIssueFile(821, ready, { + labels: [{ name: 'pear' }, { name: 'remote' }, { name: 'factory:team' }], + }) + const mount = new FakeMountClient({ [path]: issue }) + const fleet = new FailOnceReleaseFleet() + const worktrees = new RecordingWorktreeManager() + fleet.setSessionRef('ar-821-impl-pear', 'session-impl-821') + const factory = createFactory(config({ + repos: { + byLabel: { + pear: 'AgentWorkforce/pear', + remote: 'AgentWorkforce/remote', + }, + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'AgentWorkforce/pear', + }, + }), { + mount, + fleet, + triage: new StaticTriage(), + probePrResolver: async () => undefined, + worktrees, + }) + const decision = await factory.triageIssue(parseLinearIssue(path, issue)) + + await factory.dispatch(decision) + fleet.emitAgentExit('ar-821-impl-pear', 'crash') + + await vi.waitFor(() => expect(factory.status().counters.abandonedDispatchReleaseRetries).toBe(1)) + expect(factory.status().inFlight.map((entry) => entry.key)).toEqual(['AR-821']) + await vi.waitFor(() => expect(factory.status().inFlight).toEqual([]), { timeout: 3_000 }) + expect(fleet.releases.map((release) => release.name)).toContain('ar-821-impl-remote') + }) + // If the PR the implementer opened becomes visible only after the collision // (mount lag at exit time is why we fell through to resume), re-probing lets // the dispatch complete cleanly and release the reviewer instead of abandoning. @@ -7699,7 +8158,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 +8171,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 +8286,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 +10239,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([]) @@ -9763,11 +10253,13 @@ describe('FactoryLoop', () => { const path = githubIssuePath('AgentWorkforce', 'pear', 55) const mount = new CountingEventsMount({ [path]: githubIssueFile(55, { labels: ['factory'] }) }) const githubWriteback = new RecordingGithubWriteback() + const stateStore = new InMemoryStateStore({ batchSize: 4 }) const factory = createFactory(config({ issueSource: 'github' }), { mount, fleet: new FakeFleetClient(), triage: new EscalatingTriage({ rationale: 'Issue lacks acceptance criteria.' }), githubWriteback, + stateStore, }) const report = await factory.runOnce() @@ -9777,6 +10269,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(/