-
Notifications
You must be signed in to change notification settings - Fork 0
feat(factory): add multi-player session dispatch and resume plumbing #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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<void> } | ||||||
| type GithubIssueCommentWatcher = { stop(): Promise<void> } | ||||||
| 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) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Suggested change
|
||||||
| } | ||||||
| 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<void> { | ||||||
| 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, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The payload's Prompt for AI agents |
||||||
| 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, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The AgentSpec type in src/ports/fleet.ts is extended with Prompt for AI agents |
||
| 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. */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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(), | ||||||||||||||
|
Comment on lines
+16
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve the session-owner fields in the triage schema. Line 16 and Line 17 add continuation fields, but Proposed fix sessionRef: z.string().optional(),
+ principal: z.string().optional(),
+ owner: z.string().optional(),
resumeSessionId: z.string().optional(),
originCli: z.enum(['claude', 'codex']).optional(),📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| invocationId: z.string().optional(), | ||||||||||||||
| restartPolicy: z.unknown().optional(), | ||||||||||||||
| }) | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Include continuation metadata in the relay payload.
The payload only sends
sessionRef. It dropsresumeSessionIdandoriginClifrom the dispatchedAgentSpec. A receiving harness cannot select the required continuation mode.Proposed fix
type TicketDispatchRelayPayload = { eventType: 'ticket.dispatched' issue: { id: string; title: string; url: string } - agent: { name: string; sessionRef?: string } + agent: { + name: string + sessionRef?: string + resumeSessionId?: string + originCli?: 'claude' | 'codex' + } sessionOwner: string | null timestamp: string } agent: { name: agent.name, ...(tracked?.sessionRef ? { sessionRef: tracked.sessionRef } : {}), + ...(tracked?.spec.resumeSessionId ? { resumeSessionId: tracked.spec.resumeSessionId } : {}), + ...(tracked?.spec.originCli ? { originCli: tracked.spec.originCli } : {}), },Also applies to: 11133-11138
🤖 Prompt for AI Agents