@sgtbeatdown/pi-tasks 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/task-core.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  MAX_RELAY_PAYLOAD_BYTES,
3
3
  ORIGIN_CANCELLATION_OPERATION,
4
4
  PARENT_ACKNOWLEDGMENT_OPERATION,
5
+ TASK_EVENT_TYPES,
5
6
  TASK_PROTOCOL_VERSION,
6
7
  TERMINAL_INTENT_OPERATION,
7
8
  TaskDeliveryEvidenceState,
@@ -10,6 +11,7 @@ import {
10
11
  TaskOutboxDeliveryError,
11
12
  TaskProtocolError,
12
13
  isBlockedOutboxDeliveryCode,
14
+ taskEventTypes,
13
15
  } from "./task-protocol";
14
16
  import type {
15
17
  RelayDelivery,
@@ -21,27 +23,30 @@ import type {
21
23
  TaskRelay,
22
24
  TaskSnapshot,
23
25
  } from "./task-protocol";
24
- import type { TaskStore } from "./task-store";
26
+ import type { TaskStore, OutboxRecord, OutboxQuarantineRecord } from "./task-store";
27
+ import { SYSTEM_TASK_TIMERS, TASK_WORK_LIMITS, capacityRetryAt, createTaskWorkScope, deferredTaskDelivery } from "./task-work-scope";
28
+ import type { TaskShipment, TaskTimers, TaskWorkScope } from "./task-work-scope";
25
29
 
26
- const RECEIVE_PAGE_SIZE = 100;
30
+ const RECEIVE_PAGE_SIZE = TASK_WORK_LIMITS.pageItems;
27
31
  const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
28
- const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
29
- const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
30
- const CONVERSATIONAL_EVENTS = new Set(["task.information", "task.question", "task.answer"]);
31
- const CANONICAL_EVENTS = new Set([
32
- "task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out",
33
- "task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal",
34
- ]);
35
- const RECEIVER_INTENT_TYPES = new Set<TaskIntent["type"]>([
36
- "task.completed", "task.failed", "task.cancelled", "task.information", "task.question", "task.answer", "task.delivery_receipt",
37
- ]);
32
+ const RECEIVED_INTENT_OPERATION = "received_intent";
33
+ const TERMINAL_STATUSES: ReadonlySet<TaskRecord["status"]> = new Set(Object.values(TASK_EVENT_TYPES).flatMap(type => type.status ?? []));
34
+ const TERMINAL_EVENTS = taskEventTypes(type => type.terminal);
35
+ const CONVERSATIONAL_EVENTS = taskEventTypes(type => type.conversational);
36
+ const CANONICAL_EVENTS = taskEventTypes(() => true);
37
+ const RECEIVER_INTENT_TYPES = taskEventTypes(type => type.intent);
38
38
  const IGNORE_OUTBOX_FAILURES: ReadonlySet<string> = new Set();
39
+ /** The blocked-send code that records a target loss, the precondition of a reassignment. */
40
+ const TARGET_LOSS_CODE = "TARGET_NOT_REGISTERED";
41
+ const REASSIGNED_EVENT = "task.reassigned";
39
42
 
40
43
  export interface TaskCoreOptions {
41
44
  readonly endpoint: TaskEndpoint;
42
45
  readonly relay: TaskRelay;
43
46
  readonly store: TaskStore;
44
47
  readonly clock?: { readonly now: () => number };
48
+ /** Delivery budget time (deadline, slices, cooldowns); defaults to system time. Separate from `clock`, which stamps task records. */
49
+ readonly timers?: TaskTimers;
45
50
  readonly ids?: () => string;
46
51
  }
47
52
 
@@ -61,50 +66,180 @@ export type SubmitIntentOutcome =
61
66
  | { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
62
67
  | { readonly authority: "receiver" };
63
68
 
64
- /** Local wake-request evidence is persisted before Pi wake emission; relay delivery is flushed afterward. */
69
+ /** Local `wake_accepted` evidence is persisted before Pi wake emission; flush delivers it once the wake is observed. */
65
70
  export interface WakeRequest {
66
71
  flush(signal?: AbortSignal): Promise<void>;
72
+ /** Withdraws the never-sent record when Pi did not accept the wake; call synchronously after the wake attempt. */
73
+ discard(): void;
67
74
  }
68
75
 
76
+ /** One presentable inbox item, merged by store arrival order. Local items are origin-canonicalized events that never travel through the relay to the origin. */
77
+ export type TaskInboxItem =
78
+ | { readonly kind: "relay"; readonly delivery: RelayDelivery; readonly order: number }
79
+ | { readonly kind: "local"; readonly event: TaskEvent; readonly order: number };
80
+
69
81
  export interface TaskCore {
70
82
  readonly endpoint: TaskEndpoint;
71
- connect(signal?: AbortSignal): Promise<void>;
72
- createTask(input: CreateTaskInput, signal?: AbortSignal): Promise<{ readonly taskId: string }>;
83
+ connect(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
84
+ createTask(input: CreateTaskInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<{ readonly taskId: string }>;
73
85
  getTask(taskId: string): TaskSnapshot | undefined;
74
86
  listTasks(): readonly TaskSnapshot[];
75
- flushOutbox(signal?: AbortSignal): Promise<void>;
76
- receive(signal?: AbortSignal): Promise<readonly RelayDelivery[]>;
77
- acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal): Promise<void>;
78
- submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
79
- submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome>;
80
- recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
81
- recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }): WakeRequest;
82
- evaluateTimeouts(signal?: AbortSignal): Promise<void>;
83
- acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
87
+ /** True while any task is active or the outbox holds pending envelopes; drives the background poll cadence. */
88
+ hasPendingWork(): boolean;
89
+ /** Synchronous notification after each committed store change; returns the unsubscribe function. */
90
+ onChange(listener: () => void): () => void;
91
+ /** Correlate a receiver conversation with a locally recorded outbound intent, never session history or payload author claims. */
92
+ isSelfAuthoredConversation(event: TaskEvent): boolean;
93
+ /** Offers pending mail with bounded old-work retry; in a phased pass this is the explicit final retry phase. */
94
+ flushOutbox(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
95
+ receive(signal?: AbortSignal, work?: TaskWorkScope): Promise<readonly TaskInboxItem[]>;
96
+ /** Intake phase of a phased pass: persists and canonicalizes without sending or ACKing; required fan-out joins `work.shipments`. */
97
+ intakeInbox(signal?: AbortSignal, work?: TaskWorkScope): Promise<readonly TaskInboxItem[]>;
98
+ /** Ship phase of a phased pass: sends `work.shipments` in persist order and records each outcome; only abort throws. */
99
+ shipRequired(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
100
+ /** Acknowledge-phase repair: retries staged raw ACKs, then reports local deferral of a raw intent still pending. */
101
+ repairAcknowledgements(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
102
+ /** Retire a local item after its Pi insertion and wake evidence exist; no relay ACK or receipt. */
103
+ completeLocalPresentation(eventId: string): void;
104
+ acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
105
+ submitIntent(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
106
+ submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<SubmitIntentOutcome>;
107
+ recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
108
+ recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }, work?: TaskWorkScope): WakeRequest;
109
+ evaluateTimeouts(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
110
+ acknowledgeParent(taskId: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
111
+ /**
112
+ * Origin-only: after a recorded TARGET_NOT_REGISTERED loss of the current target, resolves the lost
113
+ * target's successor and sends it a `task.reassigned` assignment carrying the task history.
114
+ */
115
+ reassignTask?(taskId: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<{ readonly taskId: string; readonly target: TaskEndpoint }>;
84
116
  }
85
117
 
86
118
  type ReceivedEnvelope =
87
- | { readonly kind: "assignment"; readonly task: Omit<TaskRecord, "events">; readonly event: TaskEvent }
119
+ | { readonly kind: "assignment"; readonly task: Omit<TaskRecord, "events">; readonly event: TaskEvent; readonly history: readonly TaskEvent[] }
88
120
  | { readonly kind: "intent"; readonly task: TaskRecord; readonly intent: TaskIntent }
89
121
  | { readonly kind: "canonical_event"; readonly task: TaskRecord; readonly event: TaskEvent };
90
122
 
91
123
  export function createTaskCore(options: TaskCoreOptions): TaskCore {
92
124
  const clock = options.clock ?? { now: (): number => Date.now() };
125
+ const timers = options.timers ?? SYSTEM_TASK_TIMERS;
126
+ const workScope = (signal?: AbortSignal, mode?: "background" | "foreground" | "phased") => createTaskWorkScope(signal, mode, timers);
93
127
  const ids = options.ids ?? (() => crypto.randomUUID());
94
- const acknowledgeRelayDelivery = async (cursor: string, signal?: AbortSignal): Promise<void> => {
128
+ const flush = createOutboxFlusher(options);
129
+ const acknowledgeWithSignal = async (cursor: string, signal: AbortSignal, work: TaskWorkScope): Promise<void> => {
95
130
  if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
96
131
  const envelopeId = options.store.pendingRelayEnvelopeId(options.endpoint, cursor);
97
132
  if (envelopeId === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
133
+ const retryAt = options.store.relayAcknowledgementRetryAt(options.endpoint, cursor);
134
+ if (retryAt > timers.now() || !work.claimAcknowledgement(cursor)) throw deferredTaskDelivery({ cursor, retryAfterMs: Math.max(0, retryAt - timers.now()) });
98
135
  options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor));
99
- await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor, envelopeId }, signal);
100
- options.store.transaction(() => options.store.acknowledgeRelayCursor(options.endpoint, cursor));
136
+ try {
137
+ await waitForRelayOperation(options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor, envelopeId }, signal), signal);
138
+ throwIfAborted(signal);
139
+ options.store.transaction(() => options.store.acknowledgeRelayCursor(options.endpoint, cursor));
140
+ } catch (error) {
141
+ if (signal.aborted) throw error;
142
+ const retryAt = capacityRetryAt(error, timers.now());
143
+ if (retryAt !== undefined) options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor, retryAt));
144
+ throw error;
145
+ }
146
+ };
147
+ const acknowledgeRelayDelivery = (cursor: string, signal?: AbortSignal, work = workScope(signal)): Promise<void> => acknowledgeWithSignal(cursor, work.signal, work);
148
+ const retryRequestedAcknowledgements = async (work: TaskWorkScope): Promise<void> => {
149
+ for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint, TASK_WORK_LIMITS.acknowledgements, timers.now())) {
150
+ if (!work.canAcknowledge() || !work.canRepairAcknowledgement()) break;
151
+ if (!work.attemptedAcknowledgements.has(cursor) && options.store.pendingRelayEnvelopeId(options.endpoint, cursor) !== undefined) await acknowledgeWithSignal(cursor, work.backgroundSignal(), work);
152
+ }
153
+ };
154
+ /** Sends required envelopes now, or defers them to the ship phase of a phased pass. */
155
+ const ship = async (signal: AbortSignal, envelopeIds: readonly string[], work: TaskWorkScope, verify?: () => void): Promise<void> => {
156
+ if (work.phased) { work.shipments.push({ envelopeIds, verify, shipped: false }); return; }
157
+ try {
158
+ if (envelopeIds.length > 0) await flush(signal, new Set(envelopeIds), work);
159
+ } catch (error) {
160
+ verify?.();
161
+ throw error;
162
+ }
163
+ verify?.();
101
164
  };
102
- const retryRequestedAcknowledgements = async (signal?: AbortSignal): Promise<void> => {
103
- for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint)) {
104
- if (options.store.pendingRelayEnvelopeId(options.endpoint, cursor) !== undefined) await acknowledgeRelayDelivery(cursor, signal);
165
+ /** Ship phase: sends each shipment in persist order and records its outcome; only abort throws. */
166
+ const shipAll = async (shipments: readonly TaskShipment[], work: TaskWorkScope): Promise<void> => {
167
+ for (const shipment of shipments) {
168
+ if (shipment.shipped) continue;
169
+ let failure: unknown;
170
+ try { if (shipment.envelopeIds.length > 0) await flush(work.signal, new Set(shipment.envelopeIds), work, false); } catch (error) {
171
+ if (work.signal.aborted) throw error;
172
+ failure = error;
173
+ }
174
+ // A blocked-delivery error replaces the send failure, as for an immediate send.
175
+ try { shipment.verify?.(); } catch (error) { failure = error; }
176
+ shipment.shipped = true;
177
+ shipment.failure = failure;
178
+ if (failure === undefined) shipment.accepted?.();
179
+ }
180
+ };
181
+ /** Acknowledge-phase repair of staged raw ACKs; a raw intent still pending afterwards reports local deferral. */
182
+ const repairAcknowledgements = async (work: TaskWorkScope): Promise<void> => {
183
+ await retryRequestedAcknowledgements(work);
184
+ const pendingRaw = options.store.pendingRelayDeliveries(options.endpoint).find(pending => options.store.inboxEnvelope(pending.envelopeId)?.kind === TaskEnvelopeKind.intent);
185
+ if (pendingRaw) throw deferredTaskDelivery({ cursor: pendingRaw.cursor,
186
+ retryAfterMs: Math.max(0, options.store.relayAcknowledgementRetryAt(options.endpoint, pendingRaw.cursor) - timers.now()) });
187
+ };
188
+ /**
189
+ * Intake phase: re-drives tracked, unacknowledged relay deliveries from the local inbox, then fetches
190
+ * after the intake cursor while the pending set has room. It persists and canonicalizes without sending:
191
+ * each raw intent's required fan-out becomes a shipment whose acceptance stages that intent's ACK.
192
+ */
193
+ const intake = async (signal: AbortSignal, shipments: TaskShipment[]): Promise<readonly TaskInboxItem[]> => {
194
+ const visibleDeliveries: RelayDelivery[] = [];
195
+ const settle = (delivery: RelayDelivery, received: ReceivedEnvelope, persistedEnvelopeIds: readonly string[]): void => {
196
+ if (received.kind !== "intent") { visibleDeliveries.push(delivery); return; }
197
+ // Accepted fan-out authorizes the ACK; the checkpoint owns the staged request.
198
+ shipments.push({ envelopeIds: persistedEnvelopeIds, shipped: false, accepted: () => options.store.transaction(() => options.store.requestRelayAcknowledgement(
199
+ options.endpoint, delivery.cursor, options.store.relayAcknowledgementRetryAt(options.endpoint, delivery.cursor))) });
200
+ };
201
+ // A staged ACK request already proves accepted fanout; re-drive the rest.
202
+ const staged = new Set(options.store.requestedRelayAcknowledgements(options.endpoint, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER));
203
+ for (const pending of options.store.pendingRelayDeliveries(options.endpoint)) {
204
+ if (staged.has(pending.cursor)) continue;
205
+ const envelope = options.store.inboxEnvelope(pending.envelopeId);
206
+ if (envelope === undefined) throw new Error("pending relay delivery was not persisted");
207
+ const received = validateReceivedEnvelope(options, envelope);
208
+ const persistedEnvelopeIds = received.kind === "intent" ? options.store.transaction(() => persistReceivedEnvelope(options, clock.now, ids, received, envelope.envelopeId)) : [];
209
+ settle({ cursor: pending.cursor, envelope }, received, persistedEnvelopeIds);
210
+ }
211
+ let cursor = options.store.relayIntakeCursor(options.endpoint);
212
+ for (let room = TASK_WORK_LIMITS.pendingDeliveries - options.store.pendingRelayDeliveries(options.endpoint).length; room > 0;
213
+ room = TASK_WORK_LIMITS.pendingDeliveries - options.store.pendingRelayDeliveries(options.endpoint).length) {
214
+ const limit = Math.min(RECEIVE_PAGE_SIZE, room);
215
+ const page = await options.relay.receive({ endpoint: options.endpoint, cursor, limit }, signal);
216
+ throwIfAborted(signal);
217
+ if (!isInboxPage(page) || page.deliveries.length > limit) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
218
+ let previous = BigInt(cursor);
219
+ for (const delivery of page.deliveries) {
220
+ if (!isDelivery(delivery) || !sameEndpoint(delivery.envelope.target, options.endpoint) || BigInt(delivery.cursor) <= previous) throw new TaskProtocolError("INVALID_DELIVERY", "relay returned an invalid or unordered delivery");
221
+ previous = BigInt(delivery.cursor);
222
+ }
223
+ if (page.nextCursor !== (page.deliveries.at(-1)?.cursor ?? cursor) || (!page.deliveries.length && page.hasMore)) throw new TaskProtocolError("INVALID_INBOX", "relay page cursor does not describe its deliveries");
224
+ for (const delivery of page.deliveries) {
225
+ const received = validateReceivedEnvelope(options, delivery.envelope);
226
+ let persistedEnvelopeIds: readonly string[] = [];
227
+ options.store.transaction(() => {
228
+ // Track only with persistence: the intake cursor never passes an unpersisted delivery,
229
+ // so an abort or a rejected envelope leaves the suffix to be fetched again.
230
+ options.store.trackRelayDeliveries(options.endpoint, [{ cursor: delivery.cursor, envelopeId: delivery.envelope.envelopeId }]);
231
+ if (!options.store.persistInbox(delivery.envelope, delivery.cursor) && received.kind !== "intent") return;
232
+ persistedEnvelopeIds = persistReceivedEnvelope(options, clock.now, ids, received, delivery.envelope.envelopeId);
233
+ });
234
+ settle(delivery, received, persistedEnvelopeIds);
235
+ }
236
+ cursor = page.nextCursor;
237
+ if (!page.hasMore) break;
105
238
  }
239
+ return inboxItems(options.store, visibleDeliveries);
106
240
  };
107
- const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome> => {
241
+ const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal, work = workScope(signal, "foreground")): Promise<SubmitIntentOutcome> => {
242
+ signal = work.signal;
108
243
  if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
109
244
  let persistedEnvelopeIds: readonly string[] = [];
110
245
  let outcome: SubmitIntentOutcome | undefined;
@@ -120,10 +255,10 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
120
255
  outcome = persisted.outcome;
121
256
  });
122
257
  if (evidence !== undefined) {
123
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
258
+ await ship(signal, persistedEnvelopeIds, work, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, persistedEnvelopeIds, evidence));
124
259
  } else {
125
260
  try {
126
- await flush(options, signal, new Set(persistedEnvelopeIds));
261
+ await flush(signal, new Set(persistedEnvelopeIds), work);
127
262
  } catch (error) {
128
263
  if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
129
264
  throw error;
@@ -137,15 +272,19 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
137
272
 
138
273
  return {
139
274
  endpoint: options.endpoint,
140
- async connect(signal) {
275
+ async connect(signal, work = workScope(signal)) {
276
+ signal = work.signal;
141
277
  const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
142
278
  if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
143
279
  throwIfAborted(signal);
144
- await retryRequestedAcknowledgements(signal);
280
+ // A phased pass repairs in its acknowledge phase, after intake.
281
+ if (!work.phased) await retryRequestedAcknowledgements(work);
145
282
  },
146
- async createTask(input, signal) {
283
+ async createTask(input, signal, work = workScope(signal, "foreground")) {
284
+ signal = work.signal;
147
285
  if (input.task.length === 0 || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1) throw new TaskProtocolError("INVALID_TASK", "task and a positive timeout are required");
148
286
  const target = await options.relay.resolve({ relay: input.target.relay, reference: input.target.id }, signal);
287
+ throwIfAborted(signal);
149
288
  if (!isEndpoint(target)) throw new TaskProtocolError("INVALID_TARGET", "relay resolved an invalid task endpoint");
150
289
  const now = clock.now();
151
290
  const taskId = ids();
@@ -161,52 +300,88 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
161
300
  options.store.putOutbox(assignment);
162
301
  });
163
302
  try {
164
- await flush(options, signal, new Set([assignment.envelopeId]));
303
+ await flush(signal, new Set([assignment.envelopeId]), work);
165
304
  } catch (error) {
166
305
  throw operationError(error, taskId);
167
306
  }
168
307
  return { taskId };
169
308
  },
309
+ async reassignTask(taskId, signal, work = workScope(signal, "foreground")) {
310
+ signal = work.signal;
311
+ const lost = lostTarget(options, taskId).target;
312
+ const target = await options.relay.resolve({ relay: lost.relay, reference: lost.id }, signal);
313
+ throwIfAborted(signal);
314
+ if (!isEndpoint(target)) throw new TaskProtocolError("INVALID_TARGET", "relay resolved an invalid task endpoint");
315
+ if (sameEndpoint(target, lost)) throw reassignmentRefused(taskId, "the lost target resolved to itself");
316
+ const assignmentId = ids();
317
+ options.store.transaction(() => {
318
+ // Re-checked here: a concurrent reassignment already moved the target, so one loss allows one reassignment.
319
+ const task = lostTarget(options, taskId);
320
+ if (!sameEndpoint(task.target, lost)) throw reassignmentRefused(taskId, "the task target changed during resolution");
321
+ const now = clock.now();
322
+ const record: Omit<TaskRecord, "events"> = {
323
+ taskId, protocolVersion: task.protocolVersion, origin: task.origin, target, task: task.task,
324
+ createdAt: task.createdAt, expiresAt: task.expiresAt, status: task.status,
325
+ };
326
+ const reassigned = event(record, ids(), REASSIGNED_EVENT, String(task.events.length + 1), options.endpoint, target, now, { task: task.task, previousTarget: lost });
327
+ // Origin-local delivery evidence never travels to a target.
328
+ const history = task.events.filter(candidate => TASK_EVENT_TYPES[candidate.type]?.evidence !== true);
329
+ options.store.setTarget(taskId, target);
330
+ options.store.appendEvent(reassigned);
331
+ options.store.putOutbox(envelope(assignmentId, options.endpoint, target, taskId, TaskEnvelopeKind.assignment, reassignmentPayload(record, reassigned, history), now));
332
+ if (!sameEndpoint(target, options.endpoint)) options.store.enqueuePresentation({ taskId, eventId: reassigned.eventId });
333
+ });
334
+ try {
335
+ await flush(signal, new Set([assignmentId]), work);
336
+ } catch (error) {
337
+ throw operationError(error, taskId);
338
+ }
339
+ return { taskId, target };
340
+ },
170
341
  getTask(taskId) { return options.store.getTask(taskId); },
171
342
  listTasks() { return options.store.listTasks(); },
172
- async flushOutbox(signal) { await flush(options, signal); },
173
- async receive(signal) {
174
- await retryRequestedAcknowledgements(signal);
175
- await flush(options, signal, IGNORE_OUTBOX_FAILURES);
176
- const cursor = options.store.getReceiveCursor();
177
- const page = await options.relay.receive({ endpoint: options.endpoint, cursor, limit: RECEIVE_PAGE_SIZE }, signal);
178
- if (!isInboxPage(page)) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
179
- let previous = BigInt(cursor);
180
- for (const delivery of page.deliveries) {
181
- if (!isDelivery(delivery) || !sameEndpoint(delivery.envelope.target, options.endpoint) || BigInt(delivery.cursor) <= previous) throw new TaskProtocolError("INVALID_DELIVERY", "relay returned an invalid or unordered delivery");
182
- previous = BigInt(delivery.cursor);
183
- }
184
- if (BigInt(page.nextCursor) < previous) throw new TaskProtocolError("INVALID_INBOX", "relay page cursor precedes its deliveries");
185
- // Track the entire observed prefix before an intent can ACK a later
186
- // delivery. Keep only pending IDs plus a high-water mark, durably.
187
- options.store.transaction(() => options.store.trackRelayDeliveries(options.endpoint, page.deliveries.map(delivery => ({ cursor: delivery.cursor, envelopeId: delivery.envelope.envelopeId }))));
188
- const visibleDeliveries: RelayDelivery[] = [];
189
- for (const delivery of page.deliveries) {
190
- if (options.store.pendingRelayEnvelopeId(options.endpoint, delivery.cursor) === undefined) continue;
191
- const received = validateReceivedEnvelope(options, delivery.envelope);
192
- let persistedEnvelopeIds: readonly string[] = [];
193
- options.store.transaction(() => {
194
- if (!options.store.persistInbox(delivery.envelope, delivery.cursor)) return;
195
- persistedEnvelopeIds = persistReceivedEnvelope(options, clock.now, ids, received);
196
- });
197
- if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
198
- if (received.kind === "intent") {
199
- await this.acknowledgeRelayDelivery(delivery.cursor, signal);
200
- continue;
343
+ onChange(listener) { return options.store.onChange(listener); },
344
+ hasPendingWork() { return options.store.hasPendingOutbox() || options.store.hasActiveTasks(); },
345
+ isSelfAuthoredConversation(event) {
346
+ if (!CONVERSATIONAL_EVENTS.has(event.type) || typeof event.payload.intentId !== "string") return false;
347
+ const task = options.store.getTask(event.taskId);
348
+ if (!task || !sameEndpoint(options.endpoint, task.target) || sameEndpoint(options.endpoint, task.origin)
349
+ || !sameEndpoint(event.source, task.origin) || !sameEndpoint(event.target, task.target)) return false;
350
+ const localEnvelope = options.store.getIntentEnvelope(event.payload.intentId);
351
+ if (!localEnvelope || localEnvelope.kind !== TaskEnvelopeKind.intent || localEnvelope.taskId !== task.taskId
352
+ || !sameEndpoint(localEnvelope.source, options.endpoint) || !sameEndpoint(localEnvelope.target, task.origin)) return false;
353
+ const intent = parsePayload(localEnvelope);
354
+ return isTaskIntent(intent) && intent.taskId === event.taskId && intent.type === event.type && intent.intentId === event.payload.intentId;
355
+ },
356
+ async flushOutbox(signal, work = workScope(signal)) { await flush(work.signal, undefined, work, true); },
357
+ async receive(signal, work = workScope(signal)) {
358
+ // The phases of a pass without presentation; the first fan-out or repair failure surfaces after them.
359
+ const shipments: TaskShipment[] = [];
360
+ let items: readonly TaskInboxItem[], failure: unknown;
361
+ try {
362
+ items = await intake(work.signal, shipments);
363
+ await shipAll(shipments, work);
364
+ failure = shipments.find(shipment => shipment.failure !== undefined)?.failure;
365
+ try { await repairAcknowledgements(work); } catch (error) {
366
+ if (work.signal.aborted) throw error;
367
+ failure ??= error;
201
368
  }
202
- visibleDeliveries.push(delivery);
369
+ } finally {
370
+ // A phased driver owns the later retry share; direct receive retains its own.
371
+ if (!work.phased && !work.signal.aborted) await flush(work.signal, IGNORE_OUTBOX_FAILURES, work, true);
203
372
  }
204
- return visibleDeliveries;
373
+ if (failure !== undefined) throw failure;
374
+ return items;
205
375
  },
376
+ async intakeInbox(signal, work = workScope(signal, "phased")) { return intake(work.signal, work.shipments); },
377
+ async shipRequired(signal, work = workScope(signal, "phased")) { await shipAll(work.shipments, work); },
378
+ async repairAcknowledgements(signal, work = workScope(signal)) { await repairAcknowledgements(work); },
206
379
  acknowledgeRelayDelivery,
207
- async submitIntent(input, signal) { await submitIntentWithOutcome(input, signal); },
380
+ completeLocalPresentation(eventId) { options.store.completePresentation(eventId); },
381
+ async submitIntent(input, signal, work) { await submitIntentWithOutcome(input, signal, work); },
208
382
  submitIntentWithOutcome,
209
- async recordInsertion(input, signal) {
383
+ async recordInsertion(input, signal, work = workScope(signal)) {
384
+ signal = work.signal;
210
385
  const task = requiredTask(options.store, input.taskId);
211
386
  const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.piInserted, state: TaskDeliveryEvidenceState.confirmed };
212
387
  let persistedEnvelopeIds: readonly string[] = [];
@@ -218,24 +393,37 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
218
393
  payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
219
394
  }, deliveryEvidenceOperation(evidence)).envelopeIds;
220
395
  });
221
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
396
+ await ship(signal, persistedEnvelopeIds, work, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, persistedEnvelopeIds, evidence));
222
397
  },
223
- recordWakeRequest(input) {
224
- const task = requiredTask(options.store, input.taskId);
225
- const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed };
226
- let persistedEnvelopeIds: readonly string[] = [];
227
- options.store.transaction(() => {
228
- persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
398
+ recordWakeRequest(input, work) {
399
+ const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed };
400
+ const operation = deliveryEvidenceOperation(evidence);
401
+ const persist = () => options.store.transaction(() => {
402
+ options.store.putInsertionReceipt(input.taskId, input.eventId);
403
+ return persistIntent(options, clock.now, ids, requiredTask(options.store, input.taskId), {
229
404
  taskId: input.taskId,
230
405
  type: "task.delivery_receipt",
231
406
  payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
232
- }, deliveryEvidenceOperation(evidence)).envelopeIds;
407
+ }, operation);
233
408
  });
234
- return { flush: async (signal?: AbortSignal): Promise<void> => {
235
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
236
- } };
409
+ // An origin canonicalizes evidence straight into its task log, which cannot be withdrawn,
410
+ // so it records on flush; a receiver writes its outbound record before the wake.
411
+ let persisted = sameEndpoint(options.endpoint, requiredTask(options.store, input.taskId).origin) ? undefined : persist();
412
+ return {
413
+ flush: async (signal?: AbortSignal): Promise<void> => {
414
+ const { envelopeIds } = persisted ??= persist();
415
+ const activeWork = work ?? workScope(signal);
416
+ await ship(activeWork.signal, envelopeIds, activeWork, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, envelopeIds, evidence));
417
+ },
418
+ discard: () => {
419
+ // No await separates the write from this call, so the envelope was never offered to the relay.
420
+ if (persisted?.created) options.store.discardUnsentOperation(input.taskId, operation);
421
+ persisted = undefined;
422
+ },
423
+ };
237
424
  },
238
- async evaluateTimeouts(signal) {
425
+ async evaluateTimeouts(signal, work = workScope(signal)) {
426
+ signal = work.signal;
239
427
  const persistedEnvelopeIds: string[] = [];
240
428
  for (const candidate of options.store.listTasks()) {
241
429
  if (!sameEndpoint(candidate.origin, options.endpoint) || TERMINAL_STATUSES.has(candidate.status) || candidate.expiresAt > clock.now()) continue;
@@ -245,9 +433,10 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
245
433
  persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out").envelopeIds);
246
434
  });
247
435
  }
248
- if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
436
+ if (persistedEnvelopeIds.length > 0) await ship(signal, persistedEnvelopeIds, work);
249
437
  },
250
- async acknowledgeParent(taskId, signal) {
438
+ async acknowledgeParent(taskId, signal, work = workScope(signal)) {
439
+ signal = work.signal;
251
440
  let persistedEnvelopeIds: readonly string[] = [];
252
441
  options.store.transaction(() => {
253
442
  const task = requiredTask(options.store, taskId);
@@ -255,7 +444,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
255
444
  if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
256
445
  persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION).envelopeIds;
257
446
  });
258
- await flush(options, signal, new Set(persistedEnvelopeIds));
447
+ await flush(signal, new Set(persistedEnvelopeIds), work);
259
448
  },
260
449
  };
261
450
  }
@@ -272,6 +461,7 @@ function validateReceivedEnvelope(options: TaskCoreOptions, relayEnvelope: unkno
272
461
 
273
462
  function validateAssignment(envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
274
463
  if (!isRecord(payload) || !isTaskRecordInput(payload.task) || !isTaskEvent(payload.event)) throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment payload is malformed");
464
+ if (payload.event.type === REASSIGNED_EVENT) return validateReassignment(envelope, payload, payload.task, payload.event);
275
465
  const task = payload.task;
276
466
  const created = payload.event;
277
467
  if (envelope.taskId !== task.taskId || envelope.taskId !== created.taskId || task.protocolVersion !== TASK_PROTOCOL_VERSION
@@ -280,7 +470,66 @@ function validateAssignment(envelope: RelayEnvelope, payload: unknown): Received
280
470
  || !sameEndpoint(created.source, task.origin) || !sameEndpoint(created.target, task.target)) {
281
471
  throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment envelope headers do not match its payload");
282
472
  }
283
- return { kind: "assignment", task, event: created };
473
+ return { kind: "assignment", task, event: created, history: [] };
474
+ }
475
+
476
+ /**
477
+ * A reassignment carries history addressed to earlier targets, so each carried event is checked
478
+ * against the task and its origin, not the current target. Without `historyTruncated` it starts at `task.created`.
479
+ */
480
+ function validateReassignment(envelope: RelayEnvelope, payload: Record<string, unknown>, task: Omit<TaskRecord, "events">, reassigned: TaskEvent): ReceivedEnvelope {
481
+ const truncated = payload.historyTruncated === true;
482
+ const history = payload.history;
483
+ if (!Array.isArray(history) || !history.every(isTaskEvent) || (payload.historyTruncated !== undefined && !truncated)) throw new TaskProtocolError("INVALID_ASSIGNMENT", "reassignment payload is malformed");
484
+ let previous = 0n;
485
+ const carried = history.every(event => {
486
+ const ordered = BigInt(event.sequence) > previous;
487
+ previous = BigInt(event.sequence);
488
+ return ordered && event.taskId === task.taskId && CANONICAL_EVENTS.has(event.type) && TASK_EVENT_TYPES[event.type]?.evidence !== true && sameEndpoint(event.source, task.origin);
489
+ });
490
+ const first = history[0];
491
+ if (envelope.taskId !== task.taskId || envelope.taskId !== reassigned.taskId || task.protocolVersion !== TASK_PROTOCOL_VERSION
492
+ || task.status !== "active" || task.expiresAt < task.createdAt || reassigned.payload.task !== task.task
493
+ || !sameEndpoint(envelope.source, task.origin) || !sameEndpoint(envelope.target, task.target)
494
+ || !sameEndpoint(reassigned.source, task.origin) || !sameEndpoint(reassigned.target, task.target)
495
+ || !carried || previous >= BigInt(reassigned.sequence)
496
+ || (!truncated && (first?.type !== "task.created" || first.sequence !== "1" || first.payload.task !== task.task))) {
497
+ throw new TaskProtocolError("INVALID_ASSIGNMENT", "reassignment headers, payload or history do not match");
498
+ }
499
+ return { kind: "assignment", task, event: reassigned, history };
500
+ }
501
+
502
+ /** The task, its reassignment and as much of the latest history as fits the relay payload limit. */
503
+ function reassignmentPayload(task: Omit<TaskRecord, "events">, reassigned: TaskEvent, history: readonly TaskEvent[]): unknown {
504
+ const bytes = (value: unknown): number => new TextEncoder().encode(JSON.stringify(value)).byteLength;
505
+ const complete = { task, event: reassigned, history };
506
+ if (bytes(complete) <= MAX_RELAY_PAYLOAD_BYTES) return complete;
507
+ // Keep the newest suffix: each kept event costs its JSON plus a separating comma.
508
+ let size = bytes({ task, event: reassigned, history: [], historyTruncated: true });
509
+ let start = history.length;
510
+ while (start > 0) {
511
+ const next = size + bytes(history[start - 1]) + (start === history.length ? 0 : 1);
512
+ if (next > MAX_RELAY_PAYLOAD_BYTES) break;
513
+ size = next;
514
+ start--;
515
+ }
516
+ if (size > MAX_RELAY_PAYLOAD_BYTES) throw new TaskProtocolError("PAYLOAD_TOO_LARGE", "reassignment exceeds the relay payload limit", { retryable: false });
517
+ return { task, event: reassigned, history: history.slice(start), historyTruncated: true };
518
+ }
519
+
520
+ /** The origin's active task whose current target has a recorded TARGET_NOT_REGISTERED loss. */
521
+ function lostTarget(options: TaskCoreOptions, taskId: string): TaskSnapshot {
522
+ const task = requiredTask(options.store, taskId);
523
+ if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only the origin may reassign a task", { retryable: false });
524
+ if (task.status !== "active") throw reassignmentRefused(taskId, "the task is not active");
525
+ // Keyed on the current target: leftover envelopes to an earlier target never re-trigger.
526
+ const lost = options.store.quarantinedOutbox().some(record => record.errorCode === TARGET_LOSS_CODE && record.envelope.taskId === taskId && sameEndpoint(record.envelope.target, task.target));
527
+ if (!lost) throw reassignmentRefused(taskId, "no loss of the current target is recorded");
528
+ return task;
529
+ }
530
+
531
+ function reassignmentRefused(taskId: string, reason: string): TaskProtocolError {
532
+ return new TaskProtocolError("REASSIGNMENT_REFUSED", `task cannot be reassigned: ${reason}`, { retryable: false, details: { taskId } });
284
533
  }
285
534
 
286
535
  function validateIntent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
@@ -305,13 +554,16 @@ function validateCanonicalEvent(options: TaskCoreOptions, envelope: RelayEnvelop
305
554
  return { kind: "canonical_event", task, event: payload };
306
555
  }
307
556
 
308
- function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope): readonly string[] {
557
+ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope, envelopeId: string): readonly string[] {
309
558
  if (received.kind === "assignment") {
310
559
  options.store.putTask(received.task);
560
+ // A known task follows the reassignment; carried history is append-only and skips events already held.
561
+ if (received.event.type === REASSIGNED_EVENT) options.store.setTarget(received.task.taskId, received.task.target);
562
+ for (const carried of received.history) options.store.appendEvent(carried);
311
563
  options.store.appendEvent(received.event);
312
564
  return [];
313
565
  }
314
- if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type).envelopeIds;
566
+ if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type, `${RECEIVED_INTENT_OPERATION}:${envelopeId}`).envelopeIds;
315
567
  if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
316
568
  return [];
317
569
  }
@@ -319,16 +571,18 @@ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, id
319
571
  interface PersistedIntent {
320
572
  readonly envelopeIds: readonly string[];
321
573
  readonly outcome: SubmitIntentOutcome;
574
+ /** False when an existing operation reservation was reused. */
575
+ readonly created: boolean;
322
576
  }
323
577
 
324
578
  function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
325
579
  if (!sameEndpoint(options.endpoint, task.origin) && !sameEndpoint(options.endpoint, task.target)) throw new TaskProtocolError("NOT_PARTICIPANT", "historical task belongs to a different endpoint", { retryable: false });
326
580
  if (sameEndpoint(options.endpoint, task.origin)) {
327
581
  const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
328
- // The origin already recorded its own conversational event; echo only peer-authored conversations back for model incorporation.
329
- const echoCanonicalEventToOrigin = !CONVERSATIONAL_EVENTS.has(input.type);
330
- const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation, echoCanonicalEventToOrigin);
331
- return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } } };
582
+ // The origin already recorded its own conversational event; present only peer-authored conversations for model incorporation.
583
+ const presentToOrigin = !CONVERSATIONAL_EVENTS.has(input.type);
584
+ const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation, presentToOrigin);
585
+ return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } }, created: !canonical.reused };
332
586
  }
333
587
  const envelopeId = ids();
334
588
  const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
@@ -348,12 +602,12 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
348
602
  details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
349
603
  });
350
604
  }
351
- return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" } };
605
+ return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" }, created: false };
352
606
  }
353
607
  }
354
608
  options.store.putIntent(intent.intentId, input.taskId, envelopeId);
355
609
  options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent, now()));
356
- return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
610
+ return { envelopeIds: [envelopeId], outcome: { authority: "receiver" }, created: true };
357
611
  }
358
612
 
359
613
  interface DeliveryEvidence {
@@ -387,15 +641,18 @@ interface Canonicalization {
387
641
  readonly reused: boolean;
388
642
  }
389
643
 
390
- function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string, echoCanonicalEventToOrigin = true): Canonicalization {
644
+ /** Origin-only. The origin sees its canonical event through the local presentation queue, never a relay envelope to itself. */
645
+ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string, presentToOrigin = true): Canonicalization {
391
646
  const terminal = TERMINAL_EVENTS.has(requestedType);
392
647
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
393
648
  const sequence = String(task.events.length + 1);
394
- const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
395
- const suppressSelfTargetConversationEcho = !echoCanonicalEventToOrigin && sameEndpoint(task.origin, task.target);
396
- const targetEnvelope = suppressSelfTargetConversationEcho ? undefined : envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
397
- const originEnvelope = !echoCanonicalEventToOrigin || sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
398
- const persistedEnvelopeIds = [targetEnvelope, originEnvelope].flatMap((item) => item === undefined ? [] : [item.envelopeId]);
649
+ const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { ...intent.payload, intentId: intent.intentId });
650
+ // A self-targeted task presents through its target envelope; suppress that for its own conversations.
651
+ const selfTarget = sameEndpoint(task.origin, task.target);
652
+ // Delivery evidence is origin-local status: no fan-out to the target and no presentation.
653
+ const originLocal = TASK_EVENT_TYPES[requestedType]?.evidence === true;
654
+ const targetEnvelope = originLocal || (!presentToOrigin && selfTarget) ? undefined : envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
655
+ const persistedEnvelopeIds = targetEnvelope === undefined ? [] : [targetEnvelope.envelopeId];
399
656
  if (operation !== undefined) {
400
657
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
401
658
  if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
@@ -403,43 +660,121 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
403
660
  options.store.appendEvent(canonical);
404
661
  if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
405
662
  if (targetEnvelope !== undefined) options.store.putOutbox(targetEnvelope);
406
- if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
663
+ if (presentToOrigin && !selfTarget && !originLocal) options.store.enqueuePresentation({ taskId: task.taskId, eventId: canonical.eventId });
407
664
  return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
408
665
  }
409
666
 
410
- async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSignal | undefined, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): Promise<void> {
411
- try {
412
- if (envelopeIds.length > 0) await flush(options, signal, new Set(envelopeIds));
413
- } catch (error) {
414
- throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
415
- throw error;
667
+ function inboxItems(store: TaskStore, deliveries: readonly RelayDelivery[]): readonly TaskInboxItem[] {
668
+ const items: TaskInboxItem[] = deliveries.map((delivery) => {
669
+ const order = store.inboxOrder(delivery.envelope.envelopeId);
670
+ if (order === undefined) throw new Error("visible relay delivery was not persisted");
671
+ return { kind: "relay", delivery, order };
672
+ });
673
+ for (const pending of store.pendingPresentations()) {
674
+ const event = store.getTask(pending.taskId)?.events.find((candidate) => candidate.eventId === pending.eventId);
675
+ if (event === undefined) throw new Error("local presentation event is not in the task log");
676
+ items.push({ kind: "local", event, order: pending.order });
416
677
  }
417
- throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
418
- }
419
-
420
- async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined, reportedEnvelopeIds?: ReadonlySet<string>): Promise<void> {
421
- const failures: Array<{ readonly envelopeId: string; readonly error: unknown }> = [];
422
- for (const record of options.store.outbox("pending")) {
423
- try {
424
- await options.relay.send(record.envelope, signal);
425
- options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
426
- } catch (error) {
427
- if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
428
- if (error instanceof TaskProtocolError && isBlockedOutboxDeliveryCode(error.code) && !error.retryable) {
429
- options.store.transaction(() => {
430
- options.store.quarantineOutbox(record.envelope.envelopeId, {
431
- errorCode: error.code,
432
- reason: error.message,
433
- details: error.details ?? {},
434
- quarantinedAt: Date.now(),
435
- });
678
+ return items.sort((left, right) => left.order - right.order);
679
+ }
680
+
681
+ /** Sends the reported envelopes, then at most once per scope offers bounded old-work retry when `retry` (default: outside a phased pass). */
682
+ type OutboxFlusher = (signal: AbortSignal | undefined, reportedEnvelopeIds: ReadonlySet<string> | undefined, work: TaskWorkScope, retry?: boolean) => Promise<void>;
683
+
684
+ function createOutboxFlusher(options: TaskCoreOptions): OutboxFlusher {
685
+ const timers = options.timers ?? SYSTEM_TASK_TIMERS;
686
+ const inFlight = new Map<string, Promise<void>>();
687
+ const send = async (record: OutboxRecord, retry: boolean, work: TaskWorkScope): Promise<void> => {
688
+ const id = record.envelope.envelopeId;
689
+ // Per-target FIFO: older pending mail to the same target goes first. Wait out an
690
+ // older send already in flight instead of racing it; otherwise defer behind it.
691
+ for (let ahead = options.store.pendingOutboxAhead(id); ahead.length; ahead = options.store.pendingOutboxAhead(id)) {
692
+ const older = ahead.map(olderId => inFlight.get(olderId)).find(Boolean);
693
+ if (!older) throw deferredTaskDelivery({ envelopeId: id, blockedBy: ahead[0], retryAfterMs: Math.max(0, options.store.outboxRetryAt(ahead[0]!) - timers.now()) });
694
+ await waitForRelayOperation(older.catch(() => undefined), work.signal);
695
+ }
696
+ const joined = inFlight.get(id);
697
+ if (joined) { await waitForRelayOperation(joined, work.signal); return; }
698
+ const retryAt = options.store.outboxRetryAt(id);
699
+ if (retryAt > timers.now() || !work.claimSend(id, retry)) throw deferredTaskDelivery({ envelopeId: id, retryAfterMs: Math.max(0, retryAt - timers.now()) });
700
+ const signal = retry ? work.backgroundSignal() : work.signal;
701
+ const delivery = (async (): Promise<void> => {
702
+ try {
703
+ await waitForRelayOperation(options.relay.send(record.envelope, signal), signal);
704
+ throwIfAborted(signal);
705
+ options.store.transaction(() => options.store.markOutboxAccepted(id));
706
+ } catch (error) {
707
+ if (signal.aborted) throw error;
708
+ const retryAt = capacityRetryAt(error, timers.now());
709
+ if (retryAt !== undefined) {
710
+ options.store.postponeOutbox(id, retryAt);
711
+ // A large foreground refusal does not prove smaller old mail cannot fit.
712
+ if (retry) work.blockedRetryTargets.add(JSON.stringify(record.envelope.target));
713
+ }
714
+ if (error instanceof TaskProtocolError && isBlockedOutboxDeliveryCode(error.code) && !error.retryable) options.store.transaction(() => {
715
+ options.store.quarantineOutbox(id, { errorCode: error.code, reason: error.message, details: error.details ?? {}, quarantinedAt: timers.now() });
436
716
  });
717
+ throw error;
718
+ }
719
+ })();
720
+ inFlight.set(id, delivery);
721
+ try { await delivery; } finally { if (inFlight.get(id) === delivery) inFlight.delete(id); }
722
+ };
723
+ return async (signal, reportedEnvelopeIds, work, retry = !work.phased) => {
724
+ throwIfAborted(signal);
725
+ let failure: unknown;
726
+ if (reportedEnvelopeIds) for (const id of reportedEnvelopeIds) {
727
+ const record = options.store.getOutboxRecord(id);
728
+ if (record?.state === "accepted") continue;
729
+ if (!record) {
730
+ const quarantined = options.store.getQuarantine(id);
731
+ failure ??= quarantined ? new TaskProtocolError(quarantined.errorCode, quarantined.reason, { retryable: false, details: quarantined.details }) : deferredTaskDelivery({ envelopeId: id });
732
+ continue;
733
+ }
734
+ // Offer older pending mail to the same target first, as bounded old work (retry
735
+ // budget and background window); its failure leaves this envelope deferred.
736
+ for (const olderId of options.store.pendingOutboxAhead(id)) {
737
+ const older = options.store.getOutboxRecord(olderId);
738
+ if (!older || inFlight.has(olderId)) continue;
739
+ if (work.blockedRetryTargets.has(JSON.stringify(older.envelope.target))) break;
740
+ try { await send(older, true, work); } catch (error) { if (work.signal.aborted) throw error; break; }
741
+ }
742
+ try { await send(record, false, work); } catch (error) {
743
+ if (work.signal.aborted) throw error;
744
+ failure ??= error;
745
+ if (error instanceof TaskProtocolError && error.code === "TASK_DELIVERY_DEFERRED") break;
437
746
  }
438
- failures.push({ envelopeId: record.envelope.envelopeId, error });
439
747
  }
440
- }
441
- const reportedFailure = failures.find((failure) => reportedEnvelopeIds === undefined || reportedEnvelopeIds.has(failure.envelopeId));
442
- if (reportedFailure) throw outboxDeliveryError(reportedFailure.error);
748
+ if (retry && !work.retryStarted) {
749
+ work.retryStarted = true;
750
+ let attempts = 0;
751
+ for (let inspected = 0; inspected < TASK_WORK_LIMITS.retryInspections && attempts < TASK_WORK_LIMITS.retries; inspected++) {
752
+ if (!options.store.hasPendingOutbox() || !work.canRetry()) break;
753
+ // Advance one attempted/actually skipped ID, never an unattempted batch suffix.
754
+ const record = options.store.selectPendingOutbox(1, timers.now(), 1)[0];
755
+ if (!record) continue;
756
+ const id = record.envelope.envelopeId;
757
+ if (reportedEnvelopeIds?.has(id) || work.attemptedSends.has(id) || inFlight.has(id) || work.blockedRetryTargets.has(JSON.stringify(record.envelope.target))) continue;
758
+ attempts++;
759
+ try { await send(record, true, work); } catch (error) {
760
+ if (work.signal.aborted) throw error;
761
+ if (reportedEnvelopeIds === undefined) failure ??= error;
762
+ }
763
+ }
764
+ }
765
+ if (failure === undefined && reportedEnvelopeIds === undefined && options.store.hasPendingOutbox()) failure = deferredTaskDelivery();
766
+ if (failure !== undefined) throw outboxDeliveryError(failure);
767
+ };
768
+ }
769
+
770
+ async function waitForRelayOperation(delivery: Promise<unknown>, signal: AbortSignal): Promise<void> {
771
+ // Observe the operation first: an abort found here must not leave its later rejection unhandled.
772
+ delivery.catch(() => undefined);
773
+ throwIfAborted(signal);
774
+ let abort!: () => void;
775
+ const cancelled = new Promise<never>((_, reject) => { abort = () => reject(new TaskProtocolError("ABORTED", "task operation aborted", { details: { mayHaveBeenDelivered: true } })); signal.addEventListener("abort", abort, { once: true }); });
776
+ try { await Promise.race([delivery, cancelled]); throwIfAborted(signal); }
777
+ finally { signal.removeEventListener("abort", abort); }
443
778
  }
444
779
 
445
780
  function outboxDeliveryError(error: unknown): TaskOutboxDeliveryError {
@@ -468,9 +803,15 @@ function parsePayload(envelope: RelayEnvelope): unknown {
468
803
  try { return JSON.parse(envelope.payload) as unknown; } catch { throw new TaskProtocolError("INVALID_PAYLOAD", "relay envelope payload is not valid task protocol JSON"); }
469
804
  }
470
805
 
806
+ /** First of `envelopeIds`, in their order, that is quarantined and matches; indexed per id, not a quarantine scan. */
807
+ function firstQuarantined(store: TaskStore, envelopeIds: readonly string[], matches: (record: OutboxQuarantineRecord) => boolean = () => true): OutboxQuarantineRecord | undefined {
808
+ for (const id of envelopeIds) { const record = store.getQuarantine(id); if (record && matches(record)) return record; }
809
+ return undefined;
810
+ }
811
+
471
812
  function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: string, envelopeIds: readonly string[]): void {
472
813
  const task = requiredTask(options.store, taskId);
473
- const blocked = options.store.quarantinedOutbox().find((record) => envelopeIds.includes(record.envelope.envelopeId) && sameEndpoint(record.envelope.target, task.target));
814
+ const blocked = firstQuarantined(options.store, envelopeIds, (record) => sameEndpoint(record.envelope.target, task.target));
474
815
  if (blocked === undefined) return;
475
816
  throw new TaskOutboxDeliveryError(blocked.errorCode, blocked.reason, {
476
817
  retryable: false,
@@ -479,7 +820,7 @@ function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: st
479
820
  }
480
821
 
481
822
  function throwIfDeliveryEvidenceBlocked(store: TaskStore, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): void {
482
- const blocked = store.quarantinedOutbox().find((record) => envelopeIds.includes(record.envelope.envelopeId));
823
+ const blocked = firstQuarantined(store, envelopeIds);
483
824
  if (blocked === undefined) return;
484
825
  throw new TaskOutboxDeliveryError(blocked.errorCode, "task delivery evidence is blocked", {
485
826
  retryable: false,
@@ -512,10 +853,7 @@ function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
512
853
  }
513
854
 
514
855
  function statusFor(type: string): TaskRecord["status"] {
515
- if (type === "task.completed") return "completed";
516
- if (type === "task.failed") return "failed";
517
- if (type === "task.cancelled") return "cancelled";
518
- return "timed_out";
856
+ return TASK_EVENT_TYPES[type]?.status ?? "timed_out";
519
857
  }
520
858
 
521
859
  function isInboxPage(value: unknown): value is { readonly deliveries: readonly RelayDelivery[]; readonly nextCursor: string; readonly hasMore: boolean } {