/** Tickets: their lifecycle from an accept to their end, their Subscriptions and their Inputs. */ import { key } from './ids.ts' import { UserError } from './errors.ts' import { latestAssignment, latestReport, liveTicket, readUntil, runningAssignment, settled, threadKey, waitsForPerson } from './model.ts' import type { Assignment, ChannelRef, Evaluation, Follow, Mandate, PolicyEffect, ThreadRef, Ticket, TicketEndBy, TicketInput, TicketOutcome, Trigger, TriggerDisposition } from './model.ts' import type { AssessmentOutcome } from './ports.ts' import { EnginePart, type EngineOptions } from './part.ts' import type { Replies } from './replies.ts' import { canRetry } from './stage.ts' const sameChannel = (a: ChannelRef, b: ChannelRef): boolean => JSON.stringify(a) === JSON.stringify(b) /** Whether an Input is a message no person wrote, such as CI's, a bot's, or the main Session's note. */ export const automatic = (input: TicketInput | undefined): boolean => input?.kind === 'note' || (input?.kind === 'trigger' && !input.human) /** * Whether a ticket's worker read a Trigger Input before `until`, its latest report or turn end: its Session took the message * into a step before then. One delivered before reads were recorded counts as read once it reached the worker by then. */ const readBefore = (input: Extract, until: string): boolean => input.readAt === undefined ? (input.deliveredAt && input.deliveredAt > input.at ? input.deliveredAt : input.at) <= until : !!input.readAt && input.readAt < until const STATUS_REQUEST = 'Report the current ticket status with orchestrator_report. This is a status-only request: do not resume implementation or perform external actions.' /** * Manages each ticket's lifecycle: creates the ticket for an accept decision with its Mandate, Subscriptions, and first * Assignment, binds and attaches Threads, adds Inputs, resumes, retries, restarts, and ends tickets, and drops Subscriptions. * Coordinates with Replies for the acknowledgement an accept carries and with Executor to start, deliver to, and stop assignments. * Intake, Evals, Assignments, and Engine delegate ticket and subscription mutations here. */ export class Tickets extends EnginePart { constructor(options: EngineOptions, private readonly replies: Replies) { super(options) } /** * Create the ticket an accept decided, under `mandate`, with the Channels and Threads of the Eval's Triggers, its first * Assignment, and the acknowledgement the accept carries, and queue its launch. Its origin is the admissible Trigger that * gave the Eval its strength, or the Eval's last Trigger for an Eval recorded before `primary` existed. */ create(id: string, decisionId: string, mandate: Mandate, evaluation: Pick, outcome: Extract, at: string): void { const { triggerIds } = evaluation // Fall back to the last trigger for Evals recorded before `primary` existed. const primary = this.repo.triggers.get(evaluation.primary ?? triggerIds.at(-1)!) const origin = { triggerId: primary?.id ?? triggerIds[0]!, triggerIds } let ticket: Ticket = { id, title: outcome.title, decisionId, mandate, channels: [], assignments: [], inputs: [], undelivered: [], createdAt: at, updatedAt: at, origin } for (const triggerId of triggerIds) { const channel = this.repo.triggers.get(triggerId)?.channel if (channel && !ticket.channels.some(item => sameChannel(item, channel))) ticket.channels.push(channel) } ticket = this.options.prepareTicket?.(ticket, outcome) ?? ticket const assignment = this.newAssignment(ticket) this.repo.tickets.save(ticket) for (const triggerId of triggerIds) for (const ref of this.repo.triggers.get(triggerId)?.threads ?? []) this.subscribe(id, ref) if (outcome.acknowledgement && primary) this.replies.acknowledgeAccepted(id, primary, outcome.acknowledgement) this.options.executor.start(this.require(id), assignment) this.emit('ticket.created', { ticketId: id, decisionId, strength: mandate.strength }) } /** * Subscribes an open ticket to `thread` and records it in `ticket.follows` (`own` when opened by the ticket). * @throws UserError when the ticket is unknown or has ended. */ bind(ticketId: string, thread: ThreadRef, own: boolean): Ticket { if (!liveTicket(this.require(ticketId))) throw new UserError('ticketEnded') this.subscribe(ticketId, thread) const ticket = this.require(ticketId) const followed = threadKey(thread) const follow = ticket.follows?.find(item => item.thread === followed) // Re-binding a thread reopens it if previously closed; `own` remains true once set. if (follow) { follow.own ||= own; delete follow.closed } else ticket.follows = [...ticket.follows ?? [], { thread: followed, own }] ticket.updatedAt = this.now() this.repo.tickets.save(ticket) return ticket } /** * Record that a Thread open tickets follow closed, and end each one done with its Threads (see `followsDone`), unless its * work has not finished (see `unfinished`); such work ends the ticket once it reports ready. */ threadClosed(thread: ThreadRef, outcome: 'merged' | 'closed', at: string): void { const closed = threadKey(thread) for (const ticket of this.repo.tickets.open()) { const follow = ticket.follows?.find(item => item.thread === closed && !item.closed) if (!follow) continue follow.closed = { at, outcome } this.repo.tickets.save(ticket) const done = this.followsDone(ticket) if (!done || this.unfinished(ticket, at)) continue // Work resumed after the close and finished ends now. const resumed = Date.parse(latestAssignment(ticket)?.createdAt ?? '') > Date.parse(at) this.end(ticket.id, 'executor', done.outcome, resumed ? this.now() : done.at) } } /** * How a ticket that follows Threads is done with them: once no Thread it opened (`own`) is still open, or, when it opened none, * once every one it follows closed. It ends as the latest close among those Threads did; undefined while it is not done. */ followsDone(ticket: Ticket): Follow['closed'] { const follows = ticket.follows ?? [] const own = follows.filter(item => item.own) const counted = own.length ? own : follows if (!counted.length || counted.some(item => !item.closed)) return undefined return counted.map(item => item.closed!).sort((a, b) => a.at.localeCompare(b.at)).at(-1) } /** * The Trigger to attach to a ticket, once the ticket may take it. * @throws UserError when the trigger or ticket is unknown, a ticket's worker still decides the trigger, a person stopped the ticket, or an ended ticket's Mandate no longer revalidates. */ attachable(ticketId: string, triggerId: string): Trigger { const trigger = this.repo.triggers.get(triggerId) if (!trigger) throw new UserError('unknownTrigger') // A follow-up that reached a ticket goes elsewhere only once the workers that decide it hand it back. this.leftToWorkers(trigger) const current = this.require(ticketId) if (current.ended?.by === 'human') throw new UserError('ticketStopped') if (!liveTicket(current) && !this.policy().revalidate(current.mandate)) throw new UserError('mandateRevoked') return trigger } /** * Subscribe a ticket to an attached Trigger's Threads and add the Trigger as an Input, with its note. The ticket's worker * then decides it, as it decides a message that reached the ticket on its own Thread; a worker that does not hold the * message as its ticket's takes it as a new Input, which wakes an ended ticket as any attached message does. */ attach(ticketId: string, trigger: Trigger, note?: string): void { for (const ref of trigger.threads) this.subscribe(ticketId, ref) // A person's sending it to Eval, which routed it here, is over, and so is the ticket's reading it for context only. const current: TriggerDisposition = this.repo.triggers.disposition(trigger.id) ?? { fanout: [], at: this.now() } const { context, requeued, ...disposition } = current const followed = context?.filter(id => id !== ticketId) this.repo.triggers.saveDisposition(trigger.id, { ...disposition, fanout: [...new Set([...disposition.fanout, ticketId])], ...followed?.length ? { context: followed } : {} }) const ticket = this.require(ticketId) const id = key('input', ticketId, 'trigger', trigger.id) const existing = ticket.inputs.find(input => input.id === id) const at = this.now() const input = (inputId: string): TicketInput => ({ id: inputId, kind: 'trigger', triggerId: trigger.id, at, ...(note ? { note } : {}), ...trigger.human ? { human: true } : {} }) if (existing?.kind !== 'trigger') this.addInput(ticket, input(id)) else if (note && ticket.undelivered.includes(id)) { existing.note = note; ticket.updatedAt = at; this.repo.tickets.save(ticket) } // A person sent it to Eval, or the ticket let it go before its worker took it up, read it for context only, or ended after // its worker had it, so the worker gets it again. else if (requeued || !current.fanout.includes(ticketId) || context?.includes(ticketId) || !liveTicket(ticket)) { this.addInput(ticket, input(key('input', ticketId, 'trigger', trigger.id, 'attached', at))) } // The worker has the message already, so the note comes with it again. else if (note) this.addInput(ticket, input(key('input', ticketId, 'trigger', trigger.id, 'note', note))) } /** * Ask a running worker whose latest report is `waiting` for a status report; leaves any other ticket alone. * @returns whether the status request input was queued. * @throws UserError when the ticket is unknown. */ requestStatus(ticketId: string): boolean { const ticket = this.require(ticketId) if (!liveTicket(ticket) || runningAssignment(ticket)?.report?.state !== 'waiting') return false this.addInput(ticket, { id: `status:${ticketId}:${ticket.updatedAt}`, kind: 'text', text: STATUS_REQUEST, at: this.now() }) return true } /** * Stops a ticket on behalf of a human, ending any active assignments. * @throws UserError when the ticket is unknown. */ stop(ticketId: string, reason?: string): Ticket { this.end(ticketId, 'human', 'stopped', this.now(), reason) return this.require(ticketId) } /** * Ends the stalled assignment of a retryable open ticket (`failed`, `blocked`, or failed input delivery) and starts a new one. * @throws UserError when the ticket is unknown or not retryable. */ retry(ticketId: string): Ticket { const ticket = this.require(ticketId) const latest = latestAssignment(ticket) if (!canRetry(ticket) || !latest) throw new UserError('notRetryable') this.endAssignment(ticket, latest, 'retried') this.requeueUntaken(ticket, latest) const assignment = this.newAssignment(ticket) ticket.updatedAt = this.now() this.repo.tickets.save(ticket) this.options.executor.stop(ticket, latest, 'retried') this.options.executor.start(ticket, assignment) return ticket } /** * Drops a ticket's Subscription to a Thread its worker found unrelated, and hands back, with `reason`, the person's messages there that the ticket decides. * @throws UserError when the ticket is unknown, or `thread` is an origin thread, a bound `follows` thread, or not subscribed. */ markUnrelated(ticketId: string, thread: string, reason: string): void { const ticket = this.require(ticketId) if (this.source(ticket)?.threads.some(ref => threadKey(ref) === thread)) throw new UserError('sourceThread') if (ticket.follows?.some(item => item.thread === thread)) throw new UserError('followedThread') if (!this.repo.threads.get(thread)?.subscriptions.includes(ticketId)) throw new UserError('notFollowing') this.dropSubscriptions(thread, 'executor', ticketId, reason) // Its worker found the Thread is not its ticket's: it hands back, with its reason, the person's messages there it decides. for (const trigger of this.triggersOf(ticket)) { if (trigger.threads.some(ref => threadKey(ref) === thread) && this.owners(trigger).includes(ticketId)) this.handBack(ticketId, trigger, reason) } } /** * Tell each open ticket a Policy change bears on how it does, oldest first, so their deliveries queue in the order they * were accepted; the others are left alone. */ policyChanged(from: string, to: string, effects: ReadonlyMap): void { const at = this.now() // Iterate tickets oldest first so deliveries queue in acceptance order. for (const ticket of this.repo.tickets.open().sort((a, b) => a.createdAt.localeCompare(b.createdAt))) { const effect = effects.get(ticket.id) // Include `at` in the input key so repeated transitions between the same policy versions still record a new input. if (effect) this.addInput(ticket, { id: key('input', ticket.id, 'policy', from, to, at), kind: 'policy-changed', from, to, at, effect }) } } /** * Drop a Thread's Subscriptions, every ticket's or only `ticketId`'s, and say so; false when none dropped. A ticket that * followed the Thread follows it no more, so its close no longer ends the ticket. Each ticket keeps deciding the messages * it received from the Thread. */ dropSubscriptions(thread: string, by: 'person' | 'executor', ticketId?: string, reason?: string): boolean { const current = this.repo.threads.get(thread) const tickets = current?.subscriptions.filter(id => ticketId === undefined || id === ticketId) ?? [] if (!current || !tickets.length) return false current.subscriptions = current.subscriptions.filter(id => !tickets.includes(id)) this.repo.threads.save(current) for (const id of tickets) { const ticket = this.repo.tickets.get(id) if (!ticket?.follows?.some(item => item.thread === thread)) continue const { follows: before, ...rest } = ticket const follows = before.filter(item => item.thread !== thread) this.repo.tickets.save(follows.length ? { ...rest, follows } : rest) } for (const id of tickets) this.emit('thread.unsubscribed', { threadKey: current.key, ticketId: id, by, ...(reason ? { reason } : {}) }) return true } /** * Add an Input once (idempotently) and ask the Executor to deliver it, or to launch again a pending Assignment whose launch * failed once no person is waited for: a Policy change alone does not launch it again. */ addInput(ticket: Ticket, input: TicketInput): void { if (ticket.inputs.some(existing => existing.id === input.id)) return ticket.inputs.push(input); ticket.updatedAt = this.now() // Hold back automated inputs while the worker is waiting on a person. const held = liveTicket(ticket) && automatic(input) && waitsForPerson(ticket) if (!held) ticket.undelivered.push(input.id) this.repo.tickets.save(ticket) if (!liveTicket(ticket) || held) return const running = runningAssignment(ticket), latest = latestAssignment(ticket) if (running) this.options.executor.deliver(ticket, running) else if (latest?.state === 'pending' && !waitsForPerson(ticket)) this.options.executor.start(ticket, latest) } /** * Resume an ended ticket under its unchanged Mandate when a person's message on one of its Threads waits for it: no Eval * and no Decision, since nothing new is authorized. One whose Mandate the current Policy no longer holds stays ended and * takes nothing (see `refuse`). While processing is paused the message waits, and the first pass after the pause resumes * the ticket under the Policy then. Reads the ticket afresh, so it is idempotent and leaves an already-resumed ticket alone. */ wake(ticketId: string): void { const ticket = this.repo.tickets.get(ticketId) if (!ticket || liveTicket(ticket) || ticket.ended?.by === 'human' || !ticket.undelivered.length) return const policy = this.policy() if (policy.paused) return if (!policy.revalidate(ticket.mandate)) { this.refuse(ticket); return } // Only a human's message resumes an ended ticket; bot/CI triggers wait alongside inputs from before the ticket ended. const first = ticket.undelivered.map(id => ticket.inputs.find(input => input.id === id)).find(input => input?.kind === 'trigger' && input.human) if (first?.kind !== 'trigger') return const at = this.now() delete ticket.ended; ticket.updatedAt = at const assignment = this.newAssignment(ticket) // Prepend the resumed marker so it is delivered ahead of the waiting messages. const resumed: TicketInput = { id: key('input', ticket.id, 'resumed', first.triggerId), kind: 'resumed', triggerId: first.triggerId, at } ticket.inputs.push(resumed); ticket.undelivered.unshift(resumed.id) this.repo.tickets.save(ticket) this.options.executor.start(ticket, assignment) this.emit('ticket.resumed', { ticketId, triggerId: first.triggerId }) } /** The Triggers of a ticket's trigger Inputs, or of those of them whose Input ids are `ids`. */ private triggersOf(ticket: Ticket, ids?: readonly string[]): Trigger[] { return ticket.inputs.flatMap(input => input.kind === 'trigger' && (!ids || ids.includes(input.id)) ? this.repo.triggers.get(input.triggerId) ?? [] : []) } /** * Whether a ticket's worker took a person's message up, by acknowledging or answering it. A message acknowledged before * acknowledgements were recorded on it has only the acknowledgement's message in the outbox. */ private takenUp(ticket: Ticket, trigger: Trigger): boolean { const acked = this.repo.triggers.disposition(trigger.id)?.acked return acked ? acked.includes(ticket.id) : this.options.outbox.status(key('ack', trigger.id)) !== undefined } /** The person's messages among a ticket's Inputs `ids` that it decides and its worker did not take up. */ private untaken(ticket: Ticket, ids: readonly string[]): Trigger[] { return this.triggersOf(ticket, ids).filter(trigger => this.owners(trigger).includes(ticket.id) && !this.takenUp(ticket, trigger)) } /** * A ticket that ended, or cannot resume, lets go of the messages its worker never received, `waiting`, and of the person's * messages it decided but may not have taken up, `left`: it no longer counts as having reached them, and each of `left` * that no other ticket decides goes to Eval, as a person's message that reached no ticket does, unless a ticket took it * up, a Decision settled it, or it is the message a stop ended this ticket on. */ private letGo(ticketId: string, waiting: readonly Trigger[], left: readonly Trigger[]): void { for (const trigger of [...waiting, ...left]) { const disposition = this.repo.triggers.disposition(trigger.id) if (disposition?.fanout.includes(ticketId)) { this.repo.triggers.saveDisposition(trigger.id, { ...disposition, fanout: disposition.fanout.filter(id => id !== ticketId) }) } } for (const trigger of left) { const disposition = this.repo.triggers.disposition(trigger.id) const decision = disposition?.decisionId ? this.repo.decisions.get(disposition.decisionId) : undefined // The main Session that stopped this ticket on the message decides the rest of it itself, unless a ticket handed it back. const stopped = decision?.kind === 'stop' && decision.ticketId === ticketId && !disposition?.handbacks?.length const decided = !!decision && (settled(decision) || stopped) if (!this.owners(trigger).length && !disposition?.acked?.length && !decided) this.queueForEval(trigger) } } /** An ended ticket that cannot resume takes none of the messages that waited for it (see `letGo`). */ private refuse(ticket: Ticket): void { const waiting = this.triggersOf(ticket, ticket.undelivered), left = this.untaken(ticket, ticket.undelivered) ticket.undelivered = [] this.repo.tickets.save(ticket) this.letGo(ticket.id, waiting, left) } /** Replace a ticket's running Assignment with a new one that receives `note` first, and queue it for launch. */ restart(ticket: Ticket, running: Assignment, reason: string, note: string): void { this.endAssignment(ticket, running, reason) this.requeueUntaken(ticket, running) const assignment = this.newAssignment(ticket) const at = this.now() const input: TicketInput = { id: key('input', ticket.id, 'restart', assignment.id), kind: 'text', text: note, at } ticket.inputs.push(input); ticket.undelivered.push(input.id); ticket.updatedAt = at this.repo.tickets.save(ticket) this.options.executor.start(ticket, assignment) } /** * Put back, ahead of what waits, the Trigger Inputs delivered to `replaced` that no step of its worker took: a cancel may * have dropped them, or a step that claimed them was cut off, and the new Assignment's launch takes a copy still waiting * in the Session out, so its delivery is the only one. An earlier end let go of what earlier Assignments left. */ private requeueUntaken(ticket: Ticket, replaced: Assignment): void { const untaken = ticket.inputs.filter(input => input.kind === 'trigger' && input.readAt === null && input.deliveredTo === replaced.id && !ticket.undelivered.includes(input.id)) ticket.undelivered.unshift(...untaken.map(input => input.id)) } /** * End a ticket with its outcome. `at` records when the ending fact happened, such as a PR merge, and `reason` says * why in words, by default the outcome. The ticket lets go (see `letGo`) of the messages its worker never received, and of * the person's messages it decides that its worker did not read before its latest report or turn end, the report that * ends the ticket included (see `readBefore`), and neither acknowledged, answered nor handed back: one may have come while * the worker wrote the report that ends the ticket, or after its turn ended, and either way it can no longer hand it back. */ end(ticketId: string, by: TicketEndBy, outcome: TicketOutcome, at = this.now(), reason?: string): void { const ticket = this.require(ticketId) if (!liveTicket(ticket)) return const stopping = ticket.assignments.filter(assignment => assignment.state !== 'ended') const why = reason && reason !== outcome ? { reason } : {} const until = readUntil(ticket) const waiting = this.triggersOf(ticket, ticket.undelivered) const left = this.untaken(ticket, ticket.inputs.flatMap(input => input.kind === 'trigger' && (!readBefore(input, until) || ticket.undelivered.includes(input.id)) ? [input.id] : [])) // An end dated earlier, such as a PR's merge, does not move the ticket's last change back. ticket.ended = { at, by, outcome, ...why }; ticket.undelivered = []; ticket.updatedAt = this.now() delete ticket.attention; delete ticket.delivery // The Assignments the end stops end with it; the ticket's end says why. for (const assignment of stopping) { assignment.state = 'ended'; assignment.endedAt = at } this.repo.tickets.save(ticket) this.letGo(ticketId, waiting, left) this.emit('ticket.ended', { ticketId, by, outcome, ...why }) for (const assignment of stopping) this.options.executor.stop(ticket, assignment, reason ?? outcome) } /** * Whether a ticket's work has not finished at a close at `at`: its worker took up a person's message the ticket decides and * has not reported other than progress, or ended a turn, since its Session took that message into a step, or work resumed * after the close, whose Assignment started after it, has no ready report or Input arrived after that report; a Policy * change asks for no work. */ private unfinished(ticket: Ticket, at: string): boolean { const until = readUntil(ticket, false) const taken = this.triggersOf(ticket, ticket.inputs.flatMap(input => input.kind === 'trigger' && !readBefore(input, until) ? [input.id] : [])) .some(trigger => this.owners(trigger).includes(ticket.id) && this.takenUp(ticket, trigger)) const latest = latestAssignment(ticket) if (taken || !(Date.parse(latest?.createdAt ?? '') > Date.parse(at))) return taken const report = latest?.report const later = ticket.inputs.some(input => input.kind !== 'policy-changed' && Date.parse(input.at) > Date.parse(report?.at ?? '')) return report?.state !== 'ready' || ticket.undelivered.length > 0 || later } /** Add a pending Assignment; a failed delivery to the one before it no longer concerns the ticket. */ private newAssignment(ticket: Ticket): Assignment { const assignment: Assignment = { id: key('assignment', ticket.id, String(ticket.assignments.length)), state: 'pending', createdAt: this.now() } ticket.assignments.push(assignment) delete ticket.delivery return assignment } private endAssignment(ticket: Ticket, assignment: Assignment, reason: string): void { assignment.state = 'ended'; assignment.endedAt = this.now(); assignment.endedReason = reason this.emit('assignment.ended', { ticketId: ticket.id, assignmentId: assignment.id, reason }) } /** * Give an open ticket's Executor a fact as a note Input, such as what another ticket found; it grants nothing (see `TicketInput`). * @throws UserError when the ticket is unknown or has ended. */ note(ticketId: string, text: string): Ticket { const ticket = this.require(ticketId) if (!liveTicket(ticket)) throw new UserError('ticketEnded') const at = this.now() this.addInput(ticket, { id: key('input', ticketId, 'note', at, text), kind: 'note', text, at }) return this.require(ticketId) } /** * Subscribe an open ticket to a Thread its Executor found related, for context: see `Thread.context`. * @returns false when the ticket follows the Thread already. * @throws UserError when the ticket is unknown or has ended. */ followForContext(ticketId: string, ref: ThreadRef): boolean { if (!liveTicket(this.require(ticketId))) throw new UserError('ticketEnded') if (this.thread(ref).subscriptions.includes(ticketId)) return false this.subscribe(ticketId, ref) const thread = this.thread(ref) thread.context = [...thread.context ?? [], ticketId] this.repo.threads.save(thread) return true } /** Subscribe a ticket to a Thread; later Triggers on it reach the ticket as Inputs, as the ticket's own once it was for context. */ private subscribe(ticketId: string, ref: ThreadRef): void { const thread = this.thread(ref) if (!thread.subscriptions.includes(ticketId)) { thread.subscriptions.push(ticketId); this.emit('thread.subscribed', { threadKey: thread.key, ticketId }) } if (thread.context?.includes(ticketId)) thread.context = thread.context.filter(id => id !== ticketId) this.repo.threads.save(thread) } }