diff --git a/packages/factory-sdk/README.md b/packages/factory-sdk/README.md index ecda84f3..5d4bc759 100644 --- a/packages/factory-sdk/README.md +++ b/packages/factory-sdk/README.md @@ -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. diff --git a/packages/factory-sdk/src/config/schema.test.ts b/packages/factory-sdk/src/config/schema.test.ts index 9940ca51..8e40586f 100644 --- a/packages/factory-sdk/src/config/schema.test.ts +++ b/packages/factory-sdk/src/config/schema.test.ts @@ -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', diff --git a/packages/factory-sdk/src/config/schema.ts b/packages/factory-sdk/src/config/schema.ts index 735adcb7..31447b55 100644 --- a/packages/factory-sdk/src/config/schema.ts +++ b/packages/factory-sdk/src/config/schema.ts @@ -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({ @@ -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'), diff --git a/packages/factory-sdk/src/constants/linear.ts b/packages/factory-sdk/src/constants/linear.ts index 675d66f8..b93c3891 100644 --- a/packages/factory-sdk/src/constants/linear.ts +++ b/packages/factory-sdk/src/constants/linear.ts @@ -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 diff --git a/packages/factory-sdk/src/orchestrator/factory.test.ts b/packages/factory-sdk/src/orchestrator/factory.test.ts index d3906e67..55cab67b 100644 --- a/packages/factory-sdk/src/orchestrator/factory.test.ts +++ b/packages/factory-sdk/src/orchestrator/factory.test.ts @@ -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, 'loop' | 'safety'> & { @@ -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', @@ -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, }, }) @@ -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() @@ -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), @@ -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 }) diff --git a/packages/factory-sdk/src/orchestrator/factory.ts b/packages/factory-sdk/src/orchestrator/factory.ts index 967ec109..9fea63d3 100644 --- a/packages/factory-sdk/src/orchestrator/factory.ts +++ b/packages/factory-sdk/src/orchestrator/factory.ts @@ -197,6 +197,7 @@ export class FactoryLoop implements Factory { readonly #dispatchAttempts = new Map() readonly #canonicalIssueStates = new Map() readonly #dispatchFailureReaperHandoffs = new Map() + readonly #postMergeDoneAdvances = new Set() #slackDegraded = false #slackDegradedReason: string | undefined #slackWritebackFailureDegraded = false @@ -322,6 +323,10 @@ export class FactoryLoop implements Factory { await this.#backfillReadyIssues() this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => { + if (isGithubPullFilePath(event.resource.path)) { + void this.#handlePrChange(event.resource.path) + return + } if (isGithubIssueFilePath(event.resource.path)) { void this.#handleGithubIssueChange(event.resource.path, { dryRun: this.#config.dryRun }) return @@ -662,9 +667,7 @@ export class FactoryLoop implements Factory { #prepareLiveEventForDrain(event: ChangeEvent, seenIssueKeys: Set): string | undefined { const path = event.resource.path - // PR change events only matter when the babysitter is enabled; ignore them - // otherwise so the legacy completion path is untouched. - const isPullPath = this.#config.babysitter.enabled && isGithubPullFilePath(path) + const isPullPath = isGithubPullFilePath(path) if (!isIssueFilePath(path) && !isGithubIssueFilePath(path) && !isPullPath) { return undefined } @@ -765,7 +768,7 @@ export class FactoryLoop implements Factory { } async #handlePreparedLiveChange(path: string): Promise { - if (this.#config.babysitter.enabled && isGithubPullFilePath(path)) { + if (isGithubPullFilePath(path)) { await this.#handlePrChange(path) return } @@ -1516,7 +1519,9 @@ export class FactoryLoop implements Factory { #recordCanonicalIssueState(issue: Pick): void { const previousStateId = this.#canonicalIssueStates.get(issue.key) - if (previousStateId === this.#config.stateIds.done && issue.stateId === this.#config.stateIds.readyForAgent) { + const reopenedFromTerminal = previousStateId === this.#config.stateIds.done || + previousStateId === this.#config.stateIds.humanReview + if (reopenedFromTerminal && issue.stateId === this.#config.stateIds.readyForAgent) { const dispatchState = this.#dispatchAttempts.get(issue.key) if (dispatchState?.terminal) { dispatchState.attempts = 0 @@ -2367,13 +2372,21 @@ export class FactoryLoop implements Factory { const headRef = snapshot.headRef ?? '' const record = this.#batch.inFlight.find((candidate) => !candidate.dryRun && headRef && containsIssueKey(headRef, candidate.issue.key)) + + if (prMetaShowsMerged(snapshot)) { + await this.#advanceMergedPrToDone(snapshot, record) + return + } + + if (!this.#config.babysitter.enabled) { + return + } + if (!record) { return } if (snapshot.state && snapshot.state.trim().toUpperCase() !== 'OPEN') { - // Closed/merged PR — completion of Human Review -> Done stays with the - // merge gate / the operator; nothing for the babysitter to spawn. return } if (snapshot.draft) { @@ -2384,6 +2397,89 @@ export class FactoryLoop implements Factory { await this.#ensureBabysitter(record, { repo: `${parts.owner}/${parts.repo}`, prNumber: snapshot.number, url: snapshot.url, path }) } + async #advanceMergedPrToDone(snapshot: PullSnapshot, record?: InFlightIssue): Promise { + if (record) { + await this.#completeIssue(record, { targetState: 'done', runMergeGate: false, completionReason: 'pr-merged' }) + return + } + + const issue = await this.#findMergeAdvanceIssueForPr(snapshot) + if (!issue) { + this.#increment('mergedPrAdvanceNoIssue') + return + } + const advanceKey = `${issue.key}:${snapshot.number}` + if (this.#postMergeDoneAdvances.has(advanceKey)) { + this.#increment('mergedPrAdvanceDuplicatesSuppressed') + return + } + this.#postMergeDoneAdvances.add(advanceKey) + + try { + await this.#linear.setState(issue, this.#config.stateIds.done) + this.#recordCanonicalIssueState({ key: issue.key, stateId: this.#config.stateIds.done }) + this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path }) + this.#increment('mergedPrAdvancedDone') + this.#increment('done') + this.#logger.info?.('[factory] merged PR advanced issue to Done', { + issue: issue.key, + prNumber: snapshot.number, + url: snapshot.url, + }) + + if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) { + try { + const root = await this.#slack.postThread({ + channel: this.#config.slack.channel, + text: `${issue.key}: PR merged; Linear state set to Done.`, + }) + await this.#slack.reply(root.threadId, `${issue.key}: Linear state set to Done.`) + this.#recordSlackWritebackSuccess('merge-done-thread') + } catch (error) { + this.#markSlackWritebackFailure('merge-done-thread', error) + } + } + } catch (error) { + this.#postMergeDoneAdvances.delete(advanceKey) + this.#error(error, issueRef(issue)) + } + } + + async #findMergeAdvanceIssueForPr(snapshot: PullSnapshot): Promise { + const upstreamStates = new Set([ + this.#config.stateIds.agentImplementing, + this.#config.stateIds.humanReview, + ].filter((stateId): stateId is string => Boolean(stateId))) + let best: { issue: LinearIssue; score: number } | undefined + const scanStartedAtMs = this.#clock.now() + // This no-record path runs after agents are released, so there is no + // tracked PR identity left. Keep the scan simple and prefer branch identity + // over title/body references to avoid "related to AR-N" body false positives. + for (const path of await this.#mount.listTree(ISSUE_ROOT)) { + if (!isIssueFilePath(path)) { + continue + } + const issue = await this.#readIssue(path) + if (!issue || !upstreamStates.has(issue.stateId)) { + continue + } + if (!isRealLinearIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) { + continue + } + const score = prSnapshotIssueMatchScore(snapshot, issue.key) + if (score > 0 && (!best || score > best.score)) { + best = { issue, score } + } + } + this.#logger.debug?.('[factory] scanned Linear issues for merged PR advance', { + prNumber: snapshot.number, + durationMs: this.#clock.now() - scanStartedAtMs, + matchedIssue: best?.issue.key, + matchScore: best?.score, + }) + return best?.issue + } + // Safety net for a missed PR-open mount event: resolve the PR via the existing // probe resolver and spawn the babysitter. Triggered by an implementer exiting // after opening its PR (an event, not a poll). @@ -2498,7 +2594,7 @@ export class FactoryLoop implements Factory { issue: record.issue.key, prMetaChecked: Boolean(snapshot), }) - await this.#completeIssue(record, { humanReview: true }) + await this.#completeIssue(record) } // Re-read the babysat PR's webhook-fed meta from the mount (no gh). Prefers the @@ -2546,7 +2642,10 @@ export class FactoryLoop implements Factory { return found } - async #completeIssue(record: InFlightIssue, opts: { humanReview?: boolean } = {}): Promise { + async #completeIssue( + record: InFlightIssue, + opts: { targetState?: 'configured' | 'done'; runMergeGate?: boolean; completionReason?: 'agents-completed' | 'pr-merged' } = {}, + ): Promise { const completionKey = issueKey(record.issue) if (this.#completionInFlight.has(completionKey)) { return @@ -2556,11 +2655,11 @@ export class FactoryLoop implements Factory { // state AND configured its UUID; otherwise fall back to `done` (the legacy // terminal) so an operator who sets terminalState: 'done' keeps it and the // issue never gets stuck waiting on an unconfigured state. - const humanReview = opts.humanReview === true && + const humanReview = opts.targetState !== 'done' && this.#config.terminalState === 'human-review' && Boolean(this.#config.stateIds.humanReview) const targetState = humanReview ? this.#config.stateIds.humanReview! : this.#config.stateIds.done - const statusLabel = humanReview ? 'in human review' : 'done' + const statusLabel = humanReview ? 'In Human Review' : 'Done' try { const issue = await this.#readIssue(record.issue.path) if (issue) { @@ -2571,11 +2670,20 @@ export class FactoryLoop implements Factory { if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('completion-thread')) { try { + const merged = opts.completionReason === 'pr-merged' + const completionText = merged + ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.` + : `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}` + const stateText = merged + ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.` + : humanReview + ? `${record.issue.key}: awaiting human review; Linear state set to ${statusLabel}.` + : `${record.issue.key}: Linear state set to ${statusLabel}.` const root = await this.#slack.postThread({ channel: this.#config.slack.channel, - text: `${record.issue.key}: factory agents completed.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`, + text: completionText, }) - await this.#slack.reply(root.threadId, `${record.issue.key}: Linear state set to ${statusLabel}.`) + await this.#slack.reply(root.threadId, stateText) this.#recordSlackWritebackSuccess('completion-thread') } catch (error) { this.#markSlackWritebackFailure('completion-thread', error) @@ -2584,7 +2692,7 @@ export class FactoryLoop implements Factory { // Only auto-merge on the `done` terminal path. Human Review parks the PR // for an operator — the merge gate (which requires an APPROVED review) // would refuse anyway, and we must not merge before the human has looked. - if (issue && !humanReview) { + if (issue && !humanReview && opts.runMergeGate !== false) { await this.#runCompletionMergeGate(issue) } @@ -3671,7 +3779,7 @@ const githubPullPathParts = (path: string): { owner: string; repo: string; numbe const isGithubPullFilePath = (path: string): boolean => githubPullPathParts(path) !== undefined -type PullSnapshot = { number: number; state?: string; headRef?: string; draft?: boolean; url?: string; title?: string; merged?: boolean } +type PullSnapshot = { number: number; state?: string; headRef?: string; draft?: boolean; url?: string; title?: string; body?: string; merged?: boolean } const parsePullSnapshot = (content: unknown, fallbackNumber: number): PullSnapshot | undefined => { const payload = wrappedPayload(content) @@ -3684,10 +3792,21 @@ const parsePullSnapshot = (content: unknown, fallbackNumber: number): PullSnapsh draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft), url: stringValue(payload.url) ?? stringValue(payload.html_url), title: stringValue(payload.title), + body: stringValue(payload.body), merged: booleanValue(payload.merged), } } +const prSnapshotIssueMatchScore = (snapshot: PullSnapshot, issueKey: string): number => { + if (containsIssueKey(snapshot.headRef ?? '', issueKey)) return 30 + if (containsIssueKey(snapshot.title ?? '', issueKey)) return 20 + if (containsExplicitIssueReference(snapshot.body ?? '', issueKey)) return 10 + return 0 +} + +const prMetaShowsMerged = (snapshot: PullSnapshot): boolean => + snapshot.merged === true || snapshot.state?.trim().toUpperCase() === 'MERGED' + // Guard applied to the babysitter's ready signal: the PR's own webhook-fed meta // must still be eligible for human review. A missing `state` is treated as open // (older mount layouts omit it). The CI/conflict/review verdict is NOT re-derived