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
11 changes: 7 additions & 4 deletions packages/factory-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,13 @@ are illustrative — strip them; JSON has no comments):
the agent has nowhere to apply changes for that repo, so set it for every repo
you actually dispatch to.
- **`stateIds`** — the Linear workflow-state UUIDs for `readyForAgent`,
`agentImplementing`, `done`, `inPlanning`. Defaults are the **AR team's** states
(see [`src/constants/linear.ts`](src/constants/linear.ts)). If you run against a
different Linear team, override all four with that team's state UUIDs (read them
from the Linear API / the issue JSON's `state.id`).
`agentImplementing`, `done`, `inPlanning`, and optionally `humanReview`.
Defaults cover the **AR team's** core states (see
[`src/constants/linear.ts`](src/constants/linear.ts)). `humanReview` is
intentionally omitted from the schema default so legacy configs fall back to
direct-to-Done behavior unless they opt into a review state. If you run against
a different Linear team, override the state UUIDs with that team's values (read
them from the Linear API / the issue JSON's `state.id`).
- **`safety`** — the scope gate (below). Defaults `[factory-e2e]` + team `AR`;
GitHub issue mirrors created by the factory use `[factory]` and are accepted
by the same gate.
Expand Down
8 changes: 7 additions & 1 deletion packages/factory-sdk/src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ describe('FactoryConfigSchema', () => {
staleAfterMs: 10 * 60_000,
})
expect(parsed.mergePolicy).toBe('never')
expect(parsed.stateIds).toEqual(LINEAR_STATE_IDS)
expect(LINEAR_STATE_IDS.humanReview).toBe('24462e2d-9946-4dd1-a798-931cdd678498')
expect(parsed.stateIds).toEqual({
readyForAgent: LINEAR_STATE_IDS.readyForAgent,
agentImplementing: LINEAR_STATE_IDS.agentImplementing,
done: LINEAR_STATE_IDS.done,
inPlanning: LINEAR_STATE_IDS.inPlanning,
})
expect(parsed.safety).toEqual({
requireTitlePrefix: '[factory-e2e]',
requireLabel: 'factory',
Expand Down
14 changes: 10 additions & 4 deletions packages/factory-sdk/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { z } from 'zod'

import { LINEAR_STATE_IDS } from '../constants/linear'

const DEFAULT_STATE_IDS = {
readyForAgent: LINEAR_STATE_IDS.readyForAgent,
agentImplementing: LINEAR_STATE_IDS.agentImplementing,
done: LINEAR_STATE_IDS.done,
inPlanning: LINEAR_STATE_IDS.inPlanning,
}

export const FactoryConfigSchema = z.object({
workspaceId: z.string(),
subscription: z.object({
Expand Down Expand Up @@ -75,11 +82,10 @@ export const FactoryConfigSchema = z.object({
done: z.string(),
inPlanning: z.string(),
// The "In Human Review" workflow-state UUID. Optional and not part of the
// baked-in LINEAR_STATE_IDS default because it is workspace-specific —
// operators set it in factory.config.json. When unset, the factory falls
// back to `done` even if terminalState is `human-review`.
// default stateIds because it is workspace-specific. When unset, the factory
// falls back to `done` even if terminalState is `human-review`.
humanReview: z.string().optional(),
}).default(LINEAR_STATE_IDS),
}).default(DEFAULT_STATE_IDS),
safety: z.object({
requireTitlePrefix: z.string().min(1).default('[factory-e2e]'),
requireLabel: z.string().default('factory'),
Expand Down
4 changes: 4 additions & 0 deletions packages/factory-sdk/src/constants/linear.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// AR-team workflow state UUIDs. FactoryConfig deliberately omits humanReview
// from its default stateIds so non-AR operators must opt into their own review
// state before terminalState: 'human-review' changes behavior.
export const LINEAR_STATE_IDS = {
readyForAgent: 'b9bec744-b60c-4745-8022-d90d6ab59ae3',
agentImplementing: '39b9881d-1196-4c95-8b80-a20f0c7263f7',
humanReview: '24462e2d-9946-4dd1-a798-931cdd678498',
done: '83ea5383-bfe9-425a-86ef-517b8190f09a',
inPlanning: '3de351f2-90e6-4731-aa6b-4a55b77f481e',
} as const
Expand Down
145 changes: 144 additions & 1 deletion packages/factory-sdk/src/orchestrator/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { InternalFleetClient, type HarnessDriverClientLike } from '../fleet/inte

const ready = 'b9bec744-b60c-4745-8022-d90d6ab59ae3'
const implementing = '39b9881d-1196-4c95-8b80-a20f0c7263f7'
const humanReview = '24462e2d-9946-4dd1-a798-931cdd678498'
const done = '83ea5383-bfe9-425a-86ef-517b8190f09a'

type FactoryConfigOverrides = Omit<Partial<FactoryConfig>, 'loop' | 'safety'> & {
Expand Down Expand Up @@ -125,7 +126,7 @@ const githubIssueFile = (

const prFile = (
number: number,
payload: { title?: string; body?: string; head_ref?: string; isDraft?: boolean } = {},
payload: { title?: string; body?: string; head_ref?: string; isDraft?: boolean; state?: string; merged?: boolean } = {},
) => ({
provider: 'github',
objectType: 'pull_request',
Expand All @@ -136,6 +137,8 @@ const prFile = (
body: payload.body ?? '',
head_ref: payload.head_ref ?? `ar-${number}-test`,
isDraft: payload.isDraft,
state: payload.state,
merged: payload.merged,
},
})

Expand Down Expand Up @@ -1238,6 +1241,35 @@ describe('FactoryLoop', () => {
expect(factory.status().counters.dispatchTerminalReopened).toBe(1)
})

it('re-dispatches a terminal issue after a canonical Human Review to Ready re-open', async () => {
const factoryConfig = config({
stateIds: { readyForAgent: ready, agentImplementing: implementing, humanReview, done, inPlanning: 'state-planning' },
})
const mount = new FakeMountClient({ [issuePath(366)]: issueFile(366) })
const fleet = new FakeFleetClient()
const factory = createFactory(factoryConfig, { mount, fleet, triage: new StaticTriage() })

const first = await factory.runOnce()
expect(first.dispatched.map((result) => result.issue.key)).toEqual(['AR-366'])

fleet.emitAgentExit('ar-366-impl', 'issue-done')
await flush()
expect(factory.status().counters.humanReview).toBe(1)

await mount.writeFile(issuePath(366), issuePayload(366, ready))
const reopened = await factory.runOnce()

expect(reopened.dispatched.map((result) => result.issue.key)).toEqual(['AR-366'])
expect(reopened.skipped).toEqual([])
expect(fleet.spawns.map((spawn) => spawn.name)).toEqual([
'ar-366-impl',
'ar-366-review',
'ar-366-impl',
'ar-366-review',
])
expect(factory.status().counters.dispatchTerminalReopened).toBe(1)
})

it('does not re-dispatch a terminal issue from a stale ready alias when canonical state is still done', async () => {
const mount = new FakeMountClient({ [issuePath(365)]: issueFile(365) })
const fleet = new FakeFleetClient()
Expand Down Expand Up @@ -4629,6 +4661,41 @@ describe('FactoryLoop', () => {
expect(mount.writes).toContainEqual({ path: issuePath(240), content: { stateId: done } })
})

it('PR-state sweep parks a real issue in Human Review when configured', async () => {
const mount = new FakeMountClient({
[issuePath(243)]: realMergeIssueFile(243),
'/github/repos/AgentWorkforce__pear/pulls/by-id/243.json': prFile(243, {
title: 'Real product issue 243',
body: 'Linear: AR-243',
head_ref: 'ar-243-real-fix',
}),
})
const fleet = new FakeFleetClient()
const gate = new ScriptedGithubMergeGate([readyMergeVerdict('green-sha')])
const factory = createFactory(config({
mergePolicy: 'never',
safety: { requireTitlePrefix: 'Real', requireTeamKey: 'AR' },
stateIds: { readyForAgent: ready, agentImplementing: implementing, humanReview, done, inPlanning: 'state-planning' },
}), {
mount,
fleet,
triage: new StaticTriage(),
linear: stateOnlyLinear(mount),
mergeGate: gate,
})

await factory.dispatch(await factory.triageIssue(parseLinearIssue(issuePath(243), realMergeIssueFile(243))))
await factory.runLoop({ maxIterations: 1 })

expect(fleet.releases.map((release) => release.name)).toEqual(['ar-243-impl', 'ar-243-review'])
expect(fleet.releases.map((release) => release.reason)).toEqual(['issue-human-review', 'issue-human-review'])
expect(gate.checks).toEqual([])
expect(gate.merges).toEqual([])
expect(factory.status().inFlight).toEqual([])
expect(factory.status().counters.humanReview).toBe(1)
expect(mount.writes).toContainEqual({ path: issuePath(243), content: { stateId: humanReview } })
})

it('PR-state sweep merges a real issue only when policy, checks, review, and head are ready', async () => {
const mount = new FakeMountClient({
[issuePath(242)]: realMergeIssueFile(242),
Expand Down Expand Up @@ -6732,6 +6799,82 @@ describe('FactoryLoop PR babysitter', () => {
}
})

it('advances a Human Review issue to Done when the linked PR is merged', async () => {
const issue = realIssueFile(410, humanReviewStateId, { title: 'Real merged after review' })
const related = realIssueFile(412, humanReviewStateId, { title: 'Real related review' })
const prPath = '/github/repos/AgentWorkforce/pear/pulls/410/metadata.json'
const mount = new FakeMountClient({
[issuePath(410)]: issue,
[issuePath(412)]: related,
[prPath]: prFile(410, {
title: 'Real merged after review',
body: 'Linear: AR-412',
head_ref: 'ar-410-fix',
state: 'MERGED',
merged: true,
}),
})
const fleet = new FakeFleetClient()
const factory = createFactory(babysitterConfig(), {
mount,
fleet,
triage: new StaticTriage(),
linear: stateOnlyLinear(mount),
})

await factory.start({ mode: 'live', liveSubscription: { transport: 'subscribe' } })
try {
mount.emit(changeEvent(prPath, 'pr-410-merged'))

await vi.waitFor(() => expect(mount.writes).toContainEqual({ path: issuePath(410), content: { stateId: done } }))
expect(factory.status().counters.mergedPrAdvancedDone).toBe(1)
expect(factory.status().counters.done).toBe(1)
expect(mount.writes.some((write) => write.path === issuePath(412))).toBe(false)

mount.emit(changeEvent(prPath, 'pr-410-merged-replay'))
await flush()
expect(mount.writes.filter((write) => write.path === issuePath(410) && (write.content as { stateId?: string }).stateId === done)).toHaveLength(1)
} finally {
await factory.stop()
}
})

it('advances an in-flight implementing issue to Done if the PR merges before the ready signal', async () => {
const issue = realIssueFile(411, ready, { title: 'Real merged before ready' })
const prPath = '/github/repos/AgentWorkforce/pear/pulls/411/metadata.json'
const mount = new FakeMountClient({
[issuePath(411)]: issue,
[prPath]: prFile(411, {
title: 'Real merged before ready',
body: 'Linear: AR-411',
head_ref: 'ar-411-fix',
state: 'MERGED',
merged: true,
}),
})
const fleet = new FakeFleetClient()
const factory = createFactory(babysitterConfig(), {
mount,
fleet,
triage: new StaticTriage(),
linear: stateOnlyLinear(mount),
})

await factory.start({ mode: 'live', liveSubscription: { transport: 'subscribe' } })
try {
await factory.dispatch(await factory.triageIssue(parseLinearIssue(issuePath(411), issue)))
mount.emit(changeEvent(prPath, 'pr-411-merged'))

await vi.waitFor(() => expect(factory.status().counters.done).toBe(1))
expect(mount.writes).toContainEqual({ path: issuePath(411), content: { stateId: done } })
expect(factory.status().counters.humanReview).toBeUndefined()
expect(factory.status().inFlight).toEqual([])
expect(fleet.releases.map((release) => release.reason)).toEqual(['issue-done', 'issue-done'])
} finally {
await factory.stop()
}
})

it('with the babysitter disabled, an implementer exit still completes to done (legacy)', async () => {
const issue = realIssueFile(406, ready, { title: 'Real babysitter disabled' })
const mount = new FakeMountClient({ [issuePath(406)]: issue })
Expand Down
Loading
Loading