|
17 | 17 | * and no product chord wires it anymore — leave the path for tests/API only. |
18 | 18 | */ |
19 | 19 |
|
| 20 | +import { AgentClosedError } from "@intx/agent"; |
20 | 21 | import type { PendingImageAttachment } from "./image-attachments.js"; |
21 | | -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; |
22 | 22 | import type { ProductHostDeliver } from "./product-host.js"; |
23 | 23 | import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js"; |
24 | 24 | import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js"; |
25 | 25 |
|
| 26 | +export type AgentDeliveryNotDeliveredReason = |
| 27 | + | "agent-closed" |
| 28 | + | "session-unavailable" |
| 29 | + | "superseded" |
| 30 | + | "preparation-failed"; |
| 31 | + |
| 32 | +export type AgentDeliveryResult = |
| 33 | + | { readonly status: "accepted" } |
| 34 | + | { |
| 35 | + readonly status: "not-delivered"; |
| 36 | + readonly reason: AgentDeliveryNotDeliveredReason; |
| 37 | + readonly detail: string; |
| 38 | + } |
| 39 | + | { |
| 40 | + readonly status: "uncertain"; |
| 41 | + readonly detail: string; |
| 42 | + }; |
| 43 | + |
| 44 | +export interface DeliverAgentMessageDeps { |
| 45 | + getFatalBuildError: () => Error | null; |
| 46 | + deliverToLiveAgent: () => void; |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Guards a queued/steer deliver against a mid-rebuild or closed agent. The |
| 51 | + * shell paints the delivered row and pops the queue item before this runs, so |
| 52 | + * the caller must settle ownership from the structured result — a swallowed |
| 53 | + * failure here means the transcript claims delivery for a message that never |
| 54 | + * reached the agent. |
| 55 | + */ |
| 56 | +export async function deliverAgentMessage( |
| 57 | + deps: DeliverAgentMessageDeps, |
| 58 | +): Promise<AgentDeliveryResult> { |
| 59 | + const fatal = deps.getFatalBuildError(); |
| 60 | + if (fatal !== null) { |
| 61 | + return { |
| 62 | + status: "not-delivered", |
| 63 | + reason: "session-unavailable", |
| 64 | + detail: fatal.message, |
| 65 | + }; |
| 66 | + } |
| 67 | + try { |
| 68 | + deps.deliverToLiveAgent(); |
| 69 | + return { status: "accepted" }; |
| 70 | + } catch (err) { |
| 71 | + if (err instanceof AgentClosedError) { |
| 72 | + return { |
| 73 | + status: "not-delivered", |
| 74 | + reason: "agent-closed", |
| 75 | + detail: err.message, |
| 76 | + }; |
| 77 | + } |
| 78 | + return { |
| 79 | + status: "uncertain", |
| 80 | + detail: err instanceof Error ? err.message : String(err), |
| 81 | + }; |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * Settles a deliver that was enqueued on the serial operation queue against |
| 87 | + * the shoot generation captured at enqueue time. The queue is FIFO with no |
| 88 | + * preemption, so a deliver queued ahead of a reload still executes after the |
| 89 | + * reload has replaced the agent — the generation must be re-checked when the |
| 90 | + * queued closure runs, not just when it enqueues. A stale deliver takes the |
| 91 | + * `onStale` path (the caller reports `not-delivered`); a current deliver runs |
| 92 | + * the real settle. This is what closes the reload-vs-async-deliver race: a |
| 93 | + * reload that lands while a continuation answer is queued wins, and the stale |
| 94 | + * answer is dropped instead of reaching the replaced agent. |
| 95 | + */ |
| 96 | +export async function runGenerationGuardedDeliver(options: { |
| 97 | + stillCurrent: () => boolean; |
| 98 | + run: () => Promise<AgentDeliveryResult>; |
| 99 | + onStale: () => AgentDeliveryResult; |
| 100 | +}): Promise<AgentDeliveryResult> { |
| 101 | + if (!options.stillCurrent()) { |
| 102 | + return options.onStale(); |
| 103 | + } |
| 104 | + return options.run(); |
| 105 | +} |
| 106 | + |
| 107 | +/** Operator-facing copy for a settled delivery that did not accept. */ |
| 108 | +export function deliveryResultNotice( |
| 109 | + result: Exclude<AgentDeliveryResult, { status: "accepted" }>, |
| 110 | + disposition: "restored" | "deferred" | "none" = "none", |
| 111 | +): string { |
| 112 | + if (result.status === "uncertain") { |
| 113 | + const base = `Delivery failed: ${result.detail}. Delivery status is uncertain; review the transcript before sending again.`; |
| 114 | + return appendDisposition(base, disposition); |
| 115 | + } |
| 116 | + if (result.reason === "agent-closed") { |
| 117 | + if (disposition === "restored") { |
| 118 | + return "Message not delivered because the agent closed. It is back in the prompt; press Enter to send it."; |
| 119 | + } |
| 120 | + if (disposition === "deferred") { |
| 121 | + return "Message not delivered because the agent closed. Your current draft is unchanged; the message will return to the prompt after you send it."; |
| 122 | + } |
| 123 | + return "Message not delivered because the agent closed."; |
| 124 | + } |
| 125 | + const base = `Message not delivered: ${result.detail}`; |
| 126 | + return appendDisposition(base, disposition); |
| 127 | +} |
| 128 | + |
| 129 | +function appendDisposition( |
| 130 | + base: string, |
| 131 | + disposition: "restored" | "deferred" | "none", |
| 132 | +): string { |
| 133 | + if (disposition === "restored") { |
| 134 | + return `${base} It is back in the prompt; press Enter to send it.`; |
| 135 | + } |
| 136 | + if (disposition === "deferred") { |
| 137 | + return `${base} Your current draft is unchanged; the message will return to the prompt after you send it.`; |
| 138 | + } |
| 139 | + return base; |
| 140 | +} |
| 141 | + |
26 | 142 | export type QueueKind = "queue" | "steer"; |
27 | 143 |
|
28 | 144 | export interface QueueItem { |
|
0 commit comments