diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index da3b041..e310942 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -14,6 +14,7 @@ import { defaultGhRunner, isInFactoryScope, parseLinearIssue, + readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, readFactoryLoopHeartbeat, resolveFactoryStates, @@ -21,6 +22,7 @@ import { type Capability, type Factory, type FactoryConfig, + type IterationReport, type FleetBackend, type FleetClient, type GhRunner, @@ -75,6 +77,7 @@ type ParsedCommand = | { kind: 'release'; name: string; reason?: string } | { kind: 'factory'; action: 'run-once' | 'loop' | 'status' | 'loop-status' | 'kill-loop' | 'reap-orphans' } | { kind: 'factory'; action: 'start'; mode?: 'live' } + | { kind: 'factory-canary'; issue: string } | { kind: 'factory-triage'; issue: string } | { kind: 'factory-dispatch'; issue: string } | { kind: 'factory-close-probe'; prNumber: number; repo: string; issue: string } @@ -133,6 +136,7 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom writeJson(out, { released: command.name }) return 0 case 'factory': + case 'factory-canary': case 'factory-triage': case 'factory-dispatch': { if (!loaded) throw new Error('factory command requires config') @@ -244,7 +248,7 @@ export function parseGlobalOptions(argv: string[]): { globals: GlobalOptions; ar } async function runFactoryCommand( - command: Extract, + command: Extract, factory: Factory, mount: MountClient, fleet: FleetClient, @@ -334,6 +338,13 @@ async function runFactoryCommand( return 0 } + if (command.kind === 'factory-canary') { + const report = await factory.runOnce({ dryRun: true }) + const result = evaluateFactoryCanary(report, command.issue) + writeJson(out, result) + return result.ok ? 0 : 1 + } + const issue = await readIssueArg(mount, command.issue) const decision = await factory.triageIssue(issue) if (command.kind === 'factory-triage') { @@ -353,6 +364,10 @@ function parseFactoryCommand(args: string[]): ParsedCommand { if (action === 'run-once' || action === 'loop' || action === 'status' || action === 'loop-status' || action === 'kill-loop' || action === 'reap-orphans') { return { kind: 'factory', action } } + if (action === 'canary') { + if (!issueOrPr) throw new Error('fleet factory canary requires an issue key or path') + return { kind: 'factory-canary', issue: issueOrPr } + } if (action === 'triage') { if (!issueOrPr) throw new Error('fleet factory triage requires an issue key or path') return { kind: 'factory-triage', issue: issueOrPr } @@ -371,6 +386,45 @@ function parseFactoryCommand(args: string[]): ParsedCommand { throw new Error(`Unknown fleet factory action: ${action ?? ''}`) } +// Canary: assert a known "Ready for Agent" issue is classified dispatch-ready +// by the REAL triage path against the live mount. This is the regression +// detector for sync-fidelity drift (sparse records / stub primaries) — if it +// flips to skipped, the adapter/sync contract broke. Exits non-zero with the +// offending skip reason so CI/cron can alert. +function evaluateFactoryCanary( + report: IterationReport, + issueArg: string, +): { ok: boolean; issue: string; status: string; reason?: string } { + const wantKey = issueArg.startsWith('/') + ? (issueArg.split('/').at(-1) ?? '').replace(/\.json$/u, '').split('__')[0] + : issueArg + const matches = (ref: { key: string }): boolean => ref.key === wantKey + if (report.dispatched.some((d) => matches(d.issue))) { + return { ok: true, issue: wantKey, status: 'dispatched' } + } + if (report.triaged.some((t) => matches(t.issue))) { + return { ok: true, issue: wantKey, status: 'triaged' } + } + const skipped = report.skipped.find((s) => matches(s.issue)) + if (skipped) { + return { ok: false, issue: wantKey, status: 'skipped', reason: skipped.reason } + } + if (!report.pulled.some(matches)) { + return { + ok: false, + issue: wantKey, + status: 'not-found', + reason: 'issue was not enumerated from the mount (sync may be missing it or it is in-flight)', + } + } + return { + ok: false, + issue: wantKey, + status: 'unknown', + reason: 'issue pulled but neither dispatched, triaged, nor skipped', + } +} + function parseFactoryStartFlags(args: Array): { mode?: 'live' } { let mode: 'live' | undefined const flags = args.filter((arg): arg is string => Boolean(arg)) @@ -488,7 +542,7 @@ async function isAllowedFactoryDraft( if (nestedComment) { const issuePath = `/linear/issues/${nestedComment[1]}.json` try { - const issue = parseLinearIssue(issuePath, (await mount.readFile(issuePath)).content) + const issue = await readLinearIssueWithCanonicalFallback(mount, issuePath) return isInFactoryScope(issue, config.safety) } catch { return false @@ -498,7 +552,7 @@ async function isAllowedFactoryDraft( if (path.startsWith('/linear/issues/')) { if (isInFactoryScope(scopeIssueFromDraftContent(content), config.safety)) return true try { - const issue = parseLinearIssue(path, (await mount.readFile(path)).content) + const issue = await readLinearIssueWithCanonicalFallback(mount, path) return isInFactoryScope(issue, config.safety) } catch { return false @@ -522,8 +576,7 @@ const scopeIssueFromDraftContent = (content: unknown) => ({ async function readIssueArg(mount: MountClient, issueArg: string) { const path = issueArg.startsWith('/') ? issueArg : await findIssuePath(mount, issueArg) - const { content } = await mount.readFile(path) - return parseLinearIssue(path, content) + return readLinearIssueWithCanonicalFallback(mount, path) } async function findIssuePath(mount: MountClient, key: string): Promise { diff --git a/src/constants/linear.ts b/src/constants/linear.ts index 75f2050..8f2bf37 100644 --- a/src/constants/linear.ts +++ b/src/constants/linear.ts @@ -6,6 +6,14 @@ export const linearIssuePath = (key: string, uuid: string) => `/linear/issues/${ export const linearByStatePath = (slug: string) => `/linear/issues/by-state/${slug}/` +// Canonical record aliases. The active-issues sync writes the full issue body +// to these stable lookup paths while the primary __.json path may +// hold only a change-event STUB. The factory reads these when the primary +// parses empty (see readLinearIssueWithCanonicalFallback). +export const linearByIdPath = (key: string) => `/linear/issues/by-id/${key}.json` + +export const linearByUuidPath = (uuid: string) => `/linear/issues/by-uuid/${uuid}.json` + // Comment writeback must be nested under its issue — the relayfile cloud // writeback executor only accepts /linear/issues//comments/.json // (top-level /linear/comments/.json is rejected as "unsupported Linear diff --git a/src/index.ts b/src/index.ts index 02882d5..26ed2ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ export { issueKey, isRealLinearIssue, parseLinearIssue, + readLinearIssueWithCanonicalFallback, readFactoryInFlightRegistry, readFactoryLoopHeartbeat, reapFactoryOrphansOnce, diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 1eadbaa..4eeb23c 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -993,6 +993,64 @@ describe('FactoryLoop', () => { expect(factory.status().queued).toEqual([]) }) + it('triages a stub-primary issue by reading the canonical by-id record (sparse sync tolerance)', async () => { + // Regression (live AR-305): the active-issues sync writes a change-event + // STUB to the primary __.json path and the full — but SPARSE + // (no state.id / team / labels) — body to by-id/.json. The factory + // must read the canonical sibling and resolve readyForAgent from the state + // NAME, else every freshly-synced issue reads as "not ready-for-agent". + const stubPrimary = { + created: '2026-06-18T12:00:00.000Z', + path: issuePath(7), + externalId: 'AR-7', + ts: 1750000000, + id: 'uuid-7', + } + const canonicalById = { + provider: 'linear', + objectType: 'issue', + objectId: 'uuid-7', + payload: { + id: 'uuid-7', + identifier: 'AR-7', + title: '[factory-e2e] Add a CLI flag to redact secrets from log output', + description: 'Sparse synced record — carries state.name but no state.id, team, or labels.', + state_name: 'Ready for Agent', + priority: 2, + assignee_name: null, + url: 'https://linear.app/agent-relay/issue/AR-7/factory-issue-7', + created_at: '2026-06-18T12:00:00.000Z', + updated_at: '2026-06-18T12:00:00.000Z', + state: { name: 'Ready for Agent' }, + }, + } + const mount = new FakeMountClient({ + [issuePath(7)]: stubPrimary, + '/linear/issues/by-id/AR-7.json': canonicalById, + }) + const fleet = new FakeFleetClient() + const factory = createFactory(config({ + linear: { + states: { + readyForAgent: 'Ready for Agent', + agentImplementing: 'Implementing', + done: 'Done', + inPlanning: 'In Planning', + }, + statesByTeam: {}, + }, + }), { mount, fleet, triage: new StaticTriage() }) + + const report = await factory.runOnce({ dryRun: true }) + + // The stub must NOT be rejected as not-ready... + expect(report.skipped.find((s) => s.issue.key === 'AR-7')).toBeUndefined() + // ...and the canonical record must carry it through triage/dispatch. + const carried = report.dispatched.some((d) => d.issue.key === 'AR-7') + || report.triaged.some((t) => t.issue.key === 'AR-7') + expect(carried).toBe(true) + }) + it('mirrors factory-labeled GitHub issues from the relayfile mount into Linear create drafts', async () => { const ghPath = githubIssueNestedMetaPath('AgentWorkforce', 'pear', 1116) const mount = new FakeMountClient({ diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 1aee5e2..95dadea 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { FactoryConfigSchema, type FactoryConfig } from '../config/schema' -import { linearByStatePath } from '../constants/linear' +import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear' import { stateResolutionFromIds, type FactoryStateResolution } from '../linear/state-resolver' import { GithubMergeGate, closeProbePr, type GhRunner, type GithubMergeGate as GithubMergeGatePort } from '../github' import type { @@ -248,8 +248,10 @@ export class FactoryLoop implements Factory { this.#config = config this.#mount = ports.mount // Resolved role<->state mapping. The CLI injects a name-resolved, per-team - // resolution via ports; fall back to one built from explicit stateIds. - this.#states = ports.stateResolution ?? stateResolutionFromIds(config.stateIds) + // resolution via ports; fall back to one built from explicit stateIds plus + // the configured role NAMES so name->UUID backfill still works for sparse + // synced records (state.name but no state.id) without the states catalog. + this.#states = ports.stateResolution ?? stateResolutionFromIds(config.stateIds, config.linear.states) installFactoryDraftPredicate(this.#mount, config) this.#fleet = ports.fleet this.#triage = ports.triage ?? new TieredTriage(new HeuristicTriage()) @@ -1789,8 +1791,11 @@ export class FactoryLoop implements Factory { async #readIssue(path: string): Promise { try { - const { content } = await this.#mount.readFile(path) - const issue = parseLinearIssue(path, content) + // Newly-synced issues land as a change-event STUB at the primary + // /linear/issues/__.json path (no state/url/team); the full + // record lands at the by-id / by-uuid aliases. Read the canonical sibling + // when the primary parses empty so triage sees real state. + const issue = await readLinearIssueWithCanonicalFallback(this.#mount, path) // Synced Linear records may carry only the state NAME, not the state UUID // (relayfile-adapters#205). The factory matches state by UUID, so backfill // the id from the name when the payload omitted it — otherwise every issue @@ -3540,6 +3545,49 @@ export function parseLinearIssue(path: string, content: unknown): LinearIssue { } } +// A primary issue file that parsed without any usable state is a change-event +// STUB ({created,path,externalId,ts,id}) — the active-issues sync wrote the full +// body to the by-id/by-uuid aliases instead. Distinguishes a stub from a real +// record (which always carries at least a state name from sync). +const isUsableIssueRecord = (issue: LinearIssue): boolean => + Boolean(issue.stateId || issue.state?.name) + +// The canonical sibling records for a primary /linear/issues/__.json +// path: by-id keyed on the human key, by-uuid keyed on the Linear UUID. +const canonicalIssueRecordPaths = (path: string): string[] => { + const key = keyFromPath(path) + const uuid = uuidFromPath(path) + return [ + ...(key ? [linearByIdPath(key)] : []), + ...(uuid ? [linearByUuidPath(uuid)] : []), + ].filter((candidate) => candidate !== path) +} + +// Read an issue, falling back to the canonical by-id/by-uuid alias when the +// primary path holds only a stub. The canonical content is re-parsed against +// the ORIGINAL primary path so issue.path/key/uuid stay primary-anchored (the +// rest of the factory dedupes and dispatches by the primary path). A missing +// alias is tolerated; the original (stub) record is returned if no alias helps. +export async function readLinearIssueWithCanonicalFallback( + mount: Pick, + path: string, +): Promise { + const { content } = await mount.readFile(path) + const issue = parseLinearIssue(path, content) + if (isUsableIssueRecord(issue)) return issue + for (const candidate of canonicalIssueRecordPaths(path)) { + try { + const canonical = await mount.readFile(candidate) + const parsed = parseLinearIssue(path, canonical.content) + if (isUsableIssueRecord(parsed)) return parsed + } catch (error) { + if (isMissingIssueFileError(error)) continue + throw error + } + } + return issue +} + export function parseGithubIssue(path: string, content: unknown): GithubIssueSource { const parsed = parseJsonContent(content) const payload = wrappedPayload(parsed) diff --git a/src/orchestrator/index.ts b/src/orchestrator/index.ts index bf32730..d153186 100644 --- a/src/orchestrator/index.ts +++ b/src/orchestrator/index.ts @@ -8,6 +8,7 @@ export { FactoryLoop, isRealLinearIssue, parseLinearIssue, + readLinearIssueWithCanonicalFallback, readFactoryLoopHeartbeat, } from './factory' export {