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
63 changes: 58 additions & 5 deletions src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import {
defaultGhRunner,
isInFactoryScope,
parseLinearIssue,
readLinearIssueWithCanonicalFallback,
reapFactoryOrphansOnce,
readFactoryLoopHeartbeat,
resolveFactoryStates,
resolveFactoryWorkspace,
type Capability,
type Factory,
type FactoryConfig,
type IterationReport,
type FleetBackend,
type FleetClient,
type GhRunner,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -244,7 +248,7 @@ export function parseGlobalOptions(argv: string[]): { globals: GlobalOptions; ar
}

async function runFactoryCommand(
command: Extract<ParsedCommand, { kind: 'factory' | 'factory-triage' | 'factory-dispatch' }>,
command: Extract<ParsedCommand, { kind: 'factory' | 'factory-canary' | 'factory-triage' | 'factory-dispatch' }>,
factory: Factory,
mount: MountClient,
fleet: FleetClient,
Expand Down Expand Up @@ -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') {
Expand All @@ -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 }
Expand All @@ -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<string | undefined>): { mode?: 'live' } {
let mode: 'live' | undefined
const flags = args.filter((arg): arg is string => Boolean(arg))
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<string> {
Expand Down
8 changes: 8 additions & 0 deletions src/constants/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>__<uuid>.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/<issueRef>/comments/<draft>.json
// (top-level /linear/comments/<name>.json is rejected as "unsupported Linear
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export {
issueKey,
isRealLinearIssue,
parseLinearIssue,
readLinearIssueWithCanonicalFallback,
readFactoryInFlightRegistry,
readFactoryLoopHeartbeat,
reapFactoryOrphansOnce,
Expand Down
58 changes: 58 additions & 0 deletions src/orchestrator/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>__<uuid>.json path and the full — but SPARSE
// (no state.id / team / labels) — body to by-id/<key>.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({
Expand Down
58 changes: 53 additions & 5 deletions src/orchestrator/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -1789,8 +1791,11 @@ export class FactoryLoop implements Factory {

async #readIssue(path: string): Promise<LinearIssue | undefined> {
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/<key>__<uuid>.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
Expand Down Expand Up @@ -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/<key>__<uuid>.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)
}
Comment on lines +3557 to +3564

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In canonicalIssueRecordPaths, if the path is a by-uuid alias (e.g., /linear/issues/by-uuid/uuid-7.json), keyFromPath will return uuid-7. This results in generating an invalid lookup path /linear/issues/by-id/uuid-7.json since uuid-7 is not a valid human-readable Linear key.\n\nTo avoid unnecessary network/I/O requests to the mount client for invalid paths, we should validate that the parsed key matches the expected ISSUE_KEY_PATTERN before adding the by-id path.

const canonicalIssueRecordPaths = (path: string): string[] => {\n  const key = keyFromPath(path)\n  const uuid = uuidFromPath(path)\n  return [\n    ...(key && ISSUE_KEY_PATTERN.test(key) ? [linearByIdPath(key)] : []),\n    ...(uuid ? [linearByUuidPath(uuid)] : []),\n  ].filter((candidate) => candidate !== path)\n}


// 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<MountClient, 'readFile'>,
path: string,
): Promise<LinearIssue> {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use canonical content for real writeback guards

When the primary issue file is a stub, this parses the by-id/by-uuid content but deliberately keeps issue.path pointing at the stub primary. In a real dispatch (dryRun: false), MountLinearWriteback.canonicalForIssue re-reads issue.path for its guard (src/writeback/linear.ts:166-179) and rejects that stub because it still has no title/team, so postComment/setState fail even though triage passed; the added dry-run path won't catch this production failure. The fallback needs to make the writeback guard read/accept the canonical payload as well, or real stub-primary issues remain undispatchable.

Useful? React with 👍 / 👎.

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)
Expand Down
1 change: 1 addition & 0 deletions src/orchestrator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
FactoryLoop,
isRealLinearIssue,
parseLinearIssue,
readLinearIssueWithCanonicalFallback,
readFactoryLoopHeartbeat,
} from './factory'
export {
Expand Down