diff --git a/src/config/schema.ts b/src/config/schema.ts index ee15fec..325902e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -143,6 +143,12 @@ const reportingSchema = z.object({ requestTimeoutMs: z.number().int().min(100).max(60_000).default(15_000), }).default({}) +const hooksSchema = z.object({ + onTicketDispatch: z.object({ + relayChannel: z.string().trim().min(1), + }).optional(), +}).optional() + const previewServiceSchema = z.object({ /** Local HTTP port the repository's development server listens on. */ port: z.number().int().min(1).max(65_535), @@ -283,6 +289,7 @@ const WorkspaceConfigObjectSchema = z.object({ // analytics. It defaults on for real CLI sessions and remains no-op when no // Cloud account is available; delivery failure never changes orchestration. reporting: reportingSchema, + hooks: hooksSchema, preview: previewSchema, github: githubSchema, verification: verificationSchema, diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 3bb9a36..80176a6 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -9623,6 +9623,44 @@ describe('FactoryLoop', () => { expect(new Set(fleet.spawns.map((spawn) => spawn.invocationId)).size).toBe(2) }) + it('posts the configured onTicketDispatch relay hook with issue and session metadata', async () => { + const path = issuePath(124) + const mount = new FakeMountClient({ [path]: realIssueFile(124) }) + const fleet = new FakeFleetClient() + fleet.setSessionRef('ar-124-impl-pear', 'session-ar-124-impl-pear') + const timestamp = '2026-08-13T08:13:00.000Z' + const factory = createFactory(config({ + hooks: { onTicketDispatch: { relayChannel: 'dispatch-notifications' } }, + }), { + mount, + fleet, + triage: new StaticTriage(), + clock: { now: () => Date.parse(timestamp), sleep: async () => {} }, + }) + const decision = await factory.triageIssue(parseLinearIssue(path, realIssueFile(124))) + decision.implementers[0]!.principal = 'broker' + + await factory.dispatch(decision) + + const message = fleet.messages.find((candidate) => candidate.to === '#dispatch-notifications') + expect(message).toBeDefined() + expect(JSON.parse(message!.text)).toEqual({ + eventType: 'ticket.dispatched', + issue: { + id: 'uuid-124', + title: '[factory-e2e] Fix factory issue 124', + url: 'https://linear.app/agent-relay/issue/AR-124/factory-issue-124', + }, + agent: { + name: 'ar-124-impl-pear', + sessionRef: 'session-ar-124-impl-pear', + }, + sessionOwner: 'broker', + timestamp, + }) + expect(factory.status().counters.ticketDispatchHookNotifications).toBe(1) + }) + it('derives implementer dispatch identities from repo labels instead of triage implementers', async () => { const routedIssue = realIssueFile(720, ready, { title: '[factory-e2e] Relayfile webhooks to cloud', diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 2b8b4cb..abb2875 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -116,6 +116,13 @@ import { boundedRunCostTotal, CostLedger, type RunCostTotal, type UnpricedModelC type FactoryEvent = 'issue-queued' | 'dispatched' | 'issue-done' | 'writeback-verified' | 'error' type Listener = (payload: FactoryEventPayload) => void +type TicketDispatchRelayPayload = { + eventType: 'ticket.dispatched' + issue: { id: string; title: string; url: string } + agent: { name: string; sessionRef?: string } + sessionOwner: string | null + timestamp: string +} type SlackThreadWatcher = { stop(): Promise } type GithubIssueCommentWatcher = { stop(): Promise } type TerminationRoots = { pids: number[]; status: AgentPidResolution['status'] } @@ -2726,6 +2733,9 @@ export class FactoryLoop implements Factory { await this.#saveDispatchLifecycle(record, 'running') this.#increment('dispatched') this.#emit('dispatched', { issue: dispatchDecision.issue, result }) + if (this.#config.hooks?.onTicketDispatch && !dryRun) { + await this.#notifyTicketDispatch(decision, liveIssue, record, result) + } if (!dryRun) { await this.#ensureSlackDispatchThread(record, result) } @@ -11098,6 +11108,52 @@ export class FactoryLoop implements Factory { } } + async #notifyTicketDispatch( + decision: TriageDecision, + issue: LinearIssue, + record: InFlightIssue, + result: DispatchResult, + ): Promise { + const hook = this.#config.hooks?.onTicketDispatch + if (!hook || result.dryRun) return + + const agent = result.agents.find((candidate) => candidate.role === 'implementer') + ?? result.agents.find((candidate) => candidate.role === 'workflow') + ?? result.agents[0] + if (!agent) return + + const tracked = record.agents.get(agent.name) + const payload: TicketDispatchRelayPayload = { + eventType: 'ticket.dispatched', + issue: { + id: issue.uuid, + title: issue.title, + url: dispatchIssueUrl(issue), + }, + agent: { + name: agent.name, + ...(tracked?.sessionRef ? { sessionRef: tracked.sessionRef } : {}), + }, + sessionOwner: dispatchSessionOwner(decision) ?? null, + timestamp: new Date(this.#clock.now()).toISOString(), + } + + try { + await this.#fleet.sendMessage({ + to: `#${hook.relayChannel.replace(/^#/u, '')}`, + text: JSON.stringify(payload), + }) + this.#increment('ticketDispatchHookNotifications') + } catch (error) { + this.#increment('ticketDispatchHookFailures') + this.#logger.warn?.('[factory] onTicketDispatch relay hook failed', { + issue: issue.key, + channel: hook.relayChannel, + error: describeError(error).errorMessage, + }) + } + } + #error(error: unknown, issue?: IssueRef): void { this.#increment('errors') const details = describeError(error) @@ -13620,6 +13676,23 @@ function dispatchSpecs(decision: TriageDecision): AgentSpec[] { return [...decision.implementers, decision.reviewer] } +function dispatchSessionOwner(decision: TriageDecision): string | undefined { + for (const spec of dispatchSpecs(decision)) { + const sessionOwner = spec.principal?.trim() || spec.owner?.trim() + if (sessionOwner) return sessionOwner + } + return undefined +} + +function dispatchIssueUrl(issue: LinearIssue): string { + const payload = wrappedPayload(issue.raw) + const source = asRecord(payload.source) + return stringValue(payload.url) + ?? stringValue(payload.html_url) + ?? stringValue(source?.url) + ?? issue.path +} + function previewServiceForRepo( config: FactoryConfig, repo: string, diff --git a/src/ports/fleet.ts b/src/ports/fleet.ts index 4b736bd..f9d98bc 100644 --- a/src/ports/fleet.ts +++ b/src/ports/fleet.ts @@ -185,6 +185,10 @@ export interface FleetClient { export type AgentSpec = { name: string role: 'implementer' | 'reviewer' | 'babysitter' | 'workflow' + /** Principal that initiated the agent session, when supplied by the dispatcher. */ + principal?: string + /** Compatibility alias for dispatchers that identify the initiating principal as an owner. */ + owner?: string capability: Capability model?: string task: string @@ -197,6 +201,10 @@ export type AgentSpec = { channel?: string node?: 'self' | string sessionRef?: string + /** Canonical Relayhistory session that this agent should continue. */ + resumeSessionId?: string + /** CLI that originated the Relayhistory session, when known. */ + originCli?: 'claude' | 'codex' invocationId?: string restartPolicy?: RestartPolicy /** Durable, exact PR ownership for a lazily-spawned babysitter. */ diff --git a/src/triage/schema.ts b/src/triage/schema.ts index 887aaca..7fea252 100644 --- a/src/triage/schema.ts +++ b/src/triage/schema.ts @@ -13,6 +13,8 @@ export const AgentSpecSchema = z.object({ channel: z.string().optional(), node: z.string().optional(), sessionRef: z.string().optional(), + resumeSessionId: z.string().optional(), + originCli: z.enum(['claude', 'codex']).optional(), invocationId: z.string().optional(), restartPolicy: z.unknown().optional(), })