@sgtbeatdown/pi-tasks 0.1.15 → 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,30 +23,30 @@ import type {
21
23
  TaskRelay,
22
24
  TaskSnapshot,
23
25
  } from "./task-protocol";
24
- import type { TaskStore, OutboxRecord } from "./task-store";
25
- import { TASK_WORK_LIMITS, capacityRetryAt, createTaskWorkScope, deferredTaskDelivery } from "./task-work-scope";
26
- import type { TaskWorkScope } from "./task-work-scope";
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";
27
29
 
28
30
  const RECEIVE_PAGE_SIZE = TASK_WORK_LIMITS.pageItems;
29
31
  const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
30
32
  const RECEIVED_INTENT_OPERATION = "received_intent";
31
- const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
32
- const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
33
- const CONVERSATIONAL_EVENTS = new Set(["task.information", "task.question", "task.answer"]);
34
- const CANONICAL_EVENTS = new Set([
35
- "task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out",
36
- "task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal",
37
- ]);
38
- const RECEIVER_INTENT_TYPES = new Set<TaskIntent["type"]>([
39
- "task.completed", "task.failed", "task.cancelled", "task.information", "task.question", "task.answer", "task.delivery_receipt",
40
- ]);
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);
41
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";
42
42
 
43
43
  export interface TaskCoreOptions {
44
44
  readonly endpoint: TaskEndpoint;
45
45
  readonly relay: TaskRelay;
46
46
  readonly store: TaskStore;
47
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;
48
50
  readonly ids?: () => string;
49
51
  }
50
52
 
@@ -64,21 +66,41 @@ export type SubmitIntentOutcome =
64
66
  | { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
65
67
  | { readonly authority: "receiver" };
66
68
 
67
- /** 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. */
68
70
  export interface WakeRequest {
69
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;
70
74
  }
71
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
+
72
81
  export interface TaskCore {
73
82
  readonly endpoint: TaskEndpoint;
74
83
  connect(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
75
84
  createTask(input: CreateTaskInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<{ readonly taskId: string }>;
76
85
  getTask(taskId: string): TaskSnapshot | undefined;
77
86
  listTasks(): readonly TaskSnapshot[];
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;
78
91
  /** Correlate a receiver conversation with a locally recorded outbound intent, never session history or payload author claims. */
79
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. */
80
94
  flushOutbox(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
81
- receive(signal?: AbortSignal, work?: TaskWorkScope): Promise<readonly RelayDelivery[]>;
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;
82
104
  acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
83
105
  submitIntent(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
84
106
  submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<SubmitIntentOutcome>;
@@ -86,15 +108,22 @@ export interface TaskCore {
86
108
  recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }, work?: TaskWorkScope): WakeRequest;
87
109
  evaluateTimeouts(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
88
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 }>;
89
116
  }
90
117
 
91
118
  type ReceivedEnvelope =
92
- | { 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[] }
93
120
  | { readonly kind: "intent"; readonly task: TaskRecord; readonly intent: TaskIntent }
94
121
  | { readonly kind: "canonical_event"; readonly task: TaskRecord; readonly event: TaskEvent };
95
122
 
96
123
  export function createTaskCore(options: TaskCoreOptions): TaskCore {
97
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);
98
127
  const ids = options.ids ?? (() => crypto.randomUUID());
99
128
  const flush = createOutboxFlusher(options);
100
129
  const acknowledgeWithSignal = async (cursor: string, signal: AbortSignal, work: TaskWorkScope): Promise<void> => {
@@ -102,7 +131,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
102
131
  const envelopeId = options.store.pendingRelayEnvelopeId(options.endpoint, cursor);
103
132
  if (envelopeId === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
104
133
  const retryAt = options.store.relayAcknowledgementRetryAt(options.endpoint, cursor);
105
- if (retryAt > Date.now() || !work.claimAcknowledgement(cursor)) throw deferredTaskDelivery({ cursor, retryAfterMs: Math.max(0, retryAt - Date.now()) });
134
+ if (retryAt > timers.now() || !work.claimAcknowledgement(cursor)) throw deferredTaskDelivery({ cursor, retryAfterMs: Math.max(0, retryAt - timers.now()) });
106
135
  options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor));
107
136
  try {
108
137
  await waitForRelayOperation(options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor, envelopeId }, signal), signal);
@@ -110,19 +139,106 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
110
139
  options.store.transaction(() => options.store.acknowledgeRelayCursor(options.endpoint, cursor));
111
140
  } catch (error) {
112
141
  if (signal.aborted) throw error;
113
- const retryAt = capacityRetryAt(error);
142
+ const retryAt = capacityRetryAt(error, timers.now());
114
143
  if (retryAt !== undefined) options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor, retryAt));
115
144
  throw error;
116
145
  }
117
146
  };
118
- const acknowledgeRelayDelivery = (cursor: string, signal?: AbortSignal, work = createTaskWorkScope(signal)): Promise<void> => acknowledgeWithSignal(cursor, work.signal, work);
147
+ const acknowledgeRelayDelivery = (cursor: string, signal?: AbortSignal, work = workScope(signal)): Promise<void> => acknowledgeWithSignal(cursor, work.signal, work);
119
148
  const retryRequestedAcknowledgements = async (work: TaskWorkScope): Promise<void> => {
120
- for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint, TASK_WORK_LIMITS.acknowledgements, Date.now())) {
149
+ for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint, TASK_WORK_LIMITS.acknowledgements, timers.now())) {
121
150
  if (!work.canAcknowledge() || !work.canRepairAcknowledgement()) break;
122
151
  if (!work.attemptedAcknowledgements.has(cursor) && options.store.pendingRelayEnvelopeId(options.endpoint, cursor) !== undefined) await acknowledgeWithSignal(cursor, work.backgroundSignal(), work);
123
152
  }
124
153
  };
125
- const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal, work = createTaskWorkScope(signal, "foreground")): Promise<SubmitIntentOutcome> => {
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?.();
164
+ };
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;
238
+ }
239
+ return inboxItems(options.store, visibleDeliveries);
240
+ };
241
+ const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal, work = workScope(signal, "foreground")): Promise<SubmitIntentOutcome> => {
126
242
  signal = work.signal;
127
243
  if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
128
244
  let persistedEnvelopeIds: readonly string[] = [];
@@ -139,7 +255,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
139
255
  outcome = persisted.outcome;
140
256
  });
141
257
  if (evidence !== undefined) {
142
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence, work, flush);
258
+ await ship(signal, persistedEnvelopeIds, work, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, persistedEnvelopeIds, evidence));
143
259
  } else {
144
260
  try {
145
261
  await flush(signal, new Set(persistedEnvelopeIds), work);
@@ -156,15 +272,15 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
156
272
 
157
273
  return {
158
274
  endpoint: options.endpoint,
159
- async connect(signal, work = createTaskWorkScope(signal)) {
275
+ async connect(signal, work = workScope(signal)) {
160
276
  signal = work.signal;
161
277
  const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
162
278
  if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
163
279
  throwIfAborted(signal);
164
- // A reserved refresh repairs only after the initial page has been intaken.
165
- if (work.retryAllowed) await retryRequestedAcknowledgements(work);
280
+ // A phased pass repairs in its acknowledge phase, after intake.
281
+ if (!work.phased) await retryRequestedAcknowledgements(work);
166
282
  },
167
- async createTask(input, signal, work = createTaskWorkScope(signal, "foreground")) {
283
+ async createTask(input, signal, work = workScope(signal, "foreground")) {
168
284
  signal = work.signal;
169
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");
170
286
  const target = await options.relay.resolve({ relay: input.target.relay, reference: input.target.id }, signal);
@@ -190,8 +306,42 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
190
306
  }
191
307
  return { taskId };
192
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
+ },
193
341
  getTask(taskId) { return options.store.getTask(taskId); },
194
342
  listTasks() { return options.store.listTasks(); },
343
+ onChange(listener) { return options.store.onChange(listener); },
344
+ hasPendingWork() { return options.store.hasPendingOutbox() || options.store.hasActiveTasks(); },
195
345
  isSelfAuthoredConversation(event) {
196
346
  if (!CONVERSATIONAL_EVENTS.has(event.type) || typeof event.payload.intentId !== "string") return false;
197
347
  const task = options.store.getTask(event.taskId);
@@ -203,60 +353,34 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
203
353
  const intent = parsePayload(localEnvelope);
204
354
  return isTaskIntent(intent) && intent.taskId === event.taskId && intent.type === event.type && intent.intentId === event.payload.intentId;
205
355
  },
206
- async flushOutbox(signal, work = createTaskWorkScope(signal)) { await flush(work.signal, undefined, work); },
207
- async receive(signal, work = createTaskWorkScope(signal)) {
208
- signal = work.signal;
209
- const retryAllowed = work.retryAllowed;
210
- work.retryAllowed = false; // Nested fanout must finish this page before unrelated retries.
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;
211
361
  try {
212
- const cursor = options.store.getReceiveCursor();
213
- const page = await options.relay.receive({ endpoint: options.endpoint, cursor, limit: RECEIVE_PAGE_SIZE }, signal);
214
- throwIfAborted(signal);
215
- if (!isInboxPage(page) || page.deliveries.length > RECEIVE_PAGE_SIZE) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
216
- let previous = BigInt(cursor);
217
- for (const delivery of page.deliveries) {
218
- 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");
219
- previous = BigInt(delivery.cursor);
220
- }
221
- 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");
222
- // Track the entire observed prefix before an intent can ACK a later
223
- // delivery. Keep only pending IDs plus a high-water mark, durably.
224
- options.store.transaction(() => options.store.trackRelayDeliveries(options.endpoint, page.deliveries.map(delivery => ({ cursor: delivery.cursor, envelopeId: delivery.envelope.envelopeId }))));
225
- const visibleDeliveries: RelayDelivery[] = [];
226
- for (const delivery of page.deliveries) {
227
- if (options.store.pendingRelayEnvelopeId(options.endpoint, delivery.cursor) === undefined) continue;
228
- const received = validateReceivedEnvelope(options, delivery.envelope);
229
- let persistedEnvelopeIds: readonly string[] = [];
230
- options.store.transaction(() => {
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
- if (persistedEnvelopeIds.length > 0) await flush(signal, new Set(persistedEnvelopeIds), work);
235
- if (received.kind === "intent") {
236
- // Accepted fanout authorizes ACK, but raw repair must not gate later
237
- // facts in this fetched page. The checkpoint owns the staged request.
238
- options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, delivery.cursor,
239
- options.store.relayAcknowledgementRetryAt(options.endpoint, delivery.cursor)));
240
- continue;
241
- }
242
- visibleDeliveries.push(delivery);
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;
243
368
  }
244
- // Repair failure remains visible, but cannot gate this page's protocol intake.
245
- await retryRequestedAcknowledgements(work);
246
- const pendingRaw = page.deliveries.find(delivery => delivery.envelope.kind === "intent" && options.store.pendingRelayEnvelopeId(options.endpoint, delivery.cursor) !== undefined);
247
- if (pendingRaw) throw deferredTaskDelivery({ cursor: pendingRaw.cursor,
248
- retryAfterMs: Math.max(0, options.store.relayAcknowledgementRetryAt(options.endpoint, pendingRaw.cursor) - Date.now()) });
249
- return visibleDeliveries;
250
369
  } finally {
251
- work.retryAllowed = retryAllowed;
252
- // Inbox delivery owns the later retry share; direct receive retains its own.
253
- if (retryAllowed && !work.signal.aborted) await flush(work.signal, IGNORE_OUTBOX_FAILURES, work);
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);
254
372
  }
373
+ if (failure !== undefined) throw failure;
374
+ return items;
255
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); },
256
379
  acknowledgeRelayDelivery,
380
+ completeLocalPresentation(eventId) { options.store.completePresentation(eventId); },
257
381
  async submitIntent(input, signal, work) { await submitIntentWithOutcome(input, signal, work); },
258
382
  submitIntentWithOutcome,
259
- async recordInsertion(input, signal, work = createTaskWorkScope(signal)) {
383
+ async recordInsertion(input, signal, work = workScope(signal)) {
260
384
  signal = work.signal;
261
385
  const task = requiredTask(options.store, input.taskId);
262
386
  const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.piInserted, state: TaskDeliveryEvidenceState.confirmed };
@@ -269,25 +393,36 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
269
393
  payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
270
394
  }, deliveryEvidenceOperation(evidence)).envelopeIds;
271
395
  });
272
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence, work, flush);
396
+ await ship(signal, persistedEnvelopeIds, work, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, persistedEnvelopeIds, evidence));
273
397
  },
274
398
  recordWakeRequest(input, work) {
275
- const task = requiredTask(options.store, input.taskId);
276
- const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed };
277
- let persistedEnvelopeIds: readonly string[] = [];
278
- options.store.transaction(() => {
279
- persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
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), {
280
404
  taskId: input.taskId,
281
405
  type: "task.delivery_receipt",
282
406
  payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
283
- }, deliveryEvidenceOperation(evidence)).envelopeIds;
407
+ }, operation);
284
408
  });
285
- return { flush: async (signal?: AbortSignal): Promise<void> => {
286
- const activeWork = work ?? createTaskWorkScope(signal);
287
- await flushDeliveryEvidence(options, activeWork.signal, input.taskId, persistedEnvelopeIds, evidence, activeWork, flush);
288
- } };
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
+ };
289
424
  },
290
- async evaluateTimeouts(signal, work = createTaskWorkScope(signal)) {
425
+ async evaluateTimeouts(signal, work = workScope(signal)) {
291
426
  signal = work.signal;
292
427
  const persistedEnvelopeIds: string[] = [];
293
428
  for (const candidate of options.store.listTasks()) {
@@ -298,9 +433,9 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
298
433
  persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out").envelopeIds);
299
434
  });
300
435
  }
301
- if (persistedEnvelopeIds.length > 0) await flush(signal, new Set(persistedEnvelopeIds), work);
436
+ if (persistedEnvelopeIds.length > 0) await ship(signal, persistedEnvelopeIds, work);
302
437
  },
303
- async acknowledgeParent(taskId, signal, work = createTaskWorkScope(signal)) {
438
+ async acknowledgeParent(taskId, signal, work = workScope(signal)) {
304
439
  signal = work.signal;
305
440
  let persistedEnvelopeIds: readonly string[] = [];
306
441
  options.store.transaction(() => {
@@ -326,6 +461,7 @@ function validateReceivedEnvelope(options: TaskCoreOptions, relayEnvelope: unkno
326
461
 
327
462
  function validateAssignment(envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
328
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);
329
465
  const task = payload.task;
330
466
  const created = payload.event;
331
467
  if (envelope.taskId !== task.taskId || envelope.taskId !== created.taskId || task.protocolVersion !== TASK_PROTOCOL_VERSION
@@ -334,7 +470,66 @@ function validateAssignment(envelope: RelayEnvelope, payload: unknown): Received
334
470
  || !sameEndpoint(created.source, task.origin) || !sameEndpoint(created.target, task.target)) {
335
471
  throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment envelope headers do not match its payload");
336
472
  }
337
- 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 } });
338
533
  }
339
534
 
340
535
  function validateIntent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
@@ -362,6 +557,9 @@ function validateCanonicalEvent(options: TaskCoreOptions, envelope: RelayEnvelop
362
557
  function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope, envelopeId: string): readonly string[] {
363
558
  if (received.kind === "assignment") {
364
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);
365
563
  options.store.appendEvent(received.event);
366
564
  return [];
367
565
  }
@@ -373,16 +571,18 @@ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, id
373
571
  interface PersistedIntent {
374
572
  readonly envelopeIds: readonly string[];
375
573
  readonly outcome: SubmitIntentOutcome;
574
+ /** False when an existing operation reservation was reused. */
575
+ readonly created: boolean;
376
576
  }
377
577
 
378
578
  function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
379
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 });
380
580
  if (sameEndpoint(options.endpoint, task.origin)) {
381
581
  const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
382
- // The origin already recorded its own conversational event; echo only peer-authored conversations back for model incorporation.
383
- const echoCanonicalEventToOrigin = !CONVERSATIONAL_EVENTS.has(input.type);
384
- const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation, echoCanonicalEventToOrigin);
385
- 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 };
386
586
  }
387
587
  const envelopeId = ids();
388
588
  const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
@@ -402,12 +602,12 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
402
602
  details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
403
603
  });
404
604
  }
405
- return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" } };
605
+ return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" }, created: false };
406
606
  }
407
607
  }
408
608
  options.store.putIntent(intent.intentId, input.taskId, envelopeId);
409
609
  options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent, now()));
410
- return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
610
+ return { envelopeIds: [envelopeId], outcome: { authority: "receiver" }, created: true };
411
611
  }
412
612
 
413
613
  interface DeliveryEvidence {
@@ -441,15 +641,18 @@ interface Canonicalization {
441
641
  readonly reused: boolean;
442
642
  }
443
643
 
444
- 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 {
445
646
  const terminal = TERMINAL_EVENTS.has(requestedType);
446
647
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
447
648
  const sequence = String(task.events.length + 1);
448
649
  const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { ...intent.payload, intentId: intent.intentId });
449
- const suppressSelfTargetConversationEcho = !echoCanonicalEventToOrigin && sameEndpoint(task.origin, task.target);
450
- const targetEnvelope = suppressSelfTargetConversationEcho ? undefined : envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
451
- const originEnvelope = !echoCanonicalEventToOrigin || sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
452
- const persistedEnvelopeIds = [targetEnvelope, originEnvelope].flatMap((item) => item === undefined ? [] : [item.envelopeId]);
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];
453
656
  if (operation !== undefined) {
454
657
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
455
658
  if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
@@ -457,30 +660,43 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
457
660
  options.store.appendEvent(canonical);
458
661
  if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
459
662
  if (targetEnvelope !== undefined) options.store.putOutbox(targetEnvelope);
460
- if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
663
+ if (presentToOrigin && !selfTarget && !originLocal) options.store.enqueuePresentation({ taskId: task.taskId, eventId: canonical.eventId });
461
664
  return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
462
665
  }
463
666
 
464
- async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSignal | undefined, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence, work: TaskWorkScope, flush: OutboxFlusher): Promise<void> {
465
- try {
466
- if (envelopeIds.length > 0) await flush(signal, new Set(envelopeIds), work);
467
- } catch (error) {
468
- throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
469
- 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 });
470
677
  }
471
- throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
678
+ return items.sort((left, right) => left.order - right.order);
472
679
  }
473
680
 
474
- type OutboxFlusher = (signal: AbortSignal | undefined, reportedEnvelopeIds: ReadonlySet<string> | undefined, work: TaskWorkScope) => Promise<void>;
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>;
475
683
 
476
684
  function createOutboxFlusher(options: TaskCoreOptions): OutboxFlusher {
685
+ const timers = options.timers ?? SYSTEM_TASK_TIMERS;
477
686
  const inFlight = new Map<string, Promise<void>>();
478
687
  const send = async (record: OutboxRecord, retry: boolean, work: TaskWorkScope): Promise<void> => {
479
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
+ }
480
696
  const joined = inFlight.get(id);
481
697
  if (joined) { await waitForRelayOperation(joined, work.signal); return; }
482
698
  const retryAt = options.store.outboxRetryAt(id);
483
- if (retryAt > Date.now() || !work.claimSend(id, retry)) throw deferredTaskDelivery({ envelopeId: id, retryAfterMs: Math.max(0, retryAt - Date.now()) });
699
+ if (retryAt > timers.now() || !work.claimSend(id, retry)) throw deferredTaskDelivery({ envelopeId: id, retryAfterMs: Math.max(0, retryAt - timers.now()) });
484
700
  const signal = retry ? work.backgroundSignal() : work.signal;
485
701
  const delivery = (async (): Promise<void> => {
486
702
  try {
@@ -489,14 +705,14 @@ function createOutboxFlusher(options: TaskCoreOptions): OutboxFlusher {
489
705
  options.store.transaction(() => options.store.markOutboxAccepted(id));
490
706
  } catch (error) {
491
707
  if (signal.aborted) throw error;
492
- const retryAt = capacityRetryAt(error);
708
+ const retryAt = capacityRetryAt(error, timers.now());
493
709
  if (retryAt !== undefined) {
494
710
  options.store.postponeOutbox(id, retryAt);
495
711
  // A large foreground refusal does not prove smaller old mail cannot fit.
496
712
  if (retry) work.blockedRetryTargets.add(JSON.stringify(record.envelope.target));
497
713
  }
498
714
  if (error instanceof TaskProtocolError && isBlockedOutboxDeliveryCode(error.code) && !error.retryable) options.store.transaction(() => {
499
- options.store.quarantineOutbox(id, { errorCode: error.code, reason: error.message, details: error.details ?? {}, quarantinedAt: Date.now() });
715
+ options.store.quarantineOutbox(id, { errorCode: error.code, reason: error.message, details: error.details ?? {}, quarantinedAt: timers.now() });
500
716
  });
501
717
  throw error;
502
718
  }
@@ -504,30 +720,38 @@ function createOutboxFlusher(options: TaskCoreOptions): OutboxFlusher {
504
720
  inFlight.set(id, delivery);
505
721
  try { await delivery; } finally { if (inFlight.get(id) === delivery) inFlight.delete(id); }
506
722
  };
507
- return async (signal, reportedEnvelopeIds, work) => {
723
+ return async (signal, reportedEnvelopeIds, work, retry = !work.phased) => {
508
724
  throwIfAborted(signal);
509
725
  let failure: unknown;
510
726
  if (reportedEnvelopeIds) for (const id of reportedEnvelopeIds) {
511
727
  const record = options.store.getOutboxRecord(id);
512
728
  if (record?.state === "accepted") continue;
513
729
  if (!record) {
514
- const quarantined = options.store.quarantinedOutbox().find(item => item.envelope.envelopeId === id);
730
+ const quarantined = options.store.getQuarantine(id);
515
731
  failure ??= quarantined ? new TaskProtocolError(quarantined.errorCode, quarantined.reason, { retryable: false, details: quarantined.details }) : deferredTaskDelivery({ envelopeId: id });
516
732
  continue;
517
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
+ }
518
742
  try { await send(record, false, work); } catch (error) {
519
743
  if (work.signal.aborted) throw error;
520
744
  failure ??= error;
521
745
  if (error instanceof TaskProtocolError && error.code === "TASK_DELIVERY_DEFERRED") break;
522
746
  }
523
747
  }
524
- if (work.retryAllowed && !work.retryStarted) {
748
+ if (retry && !work.retryStarted) {
525
749
  work.retryStarted = true;
526
750
  let attempts = 0;
527
751
  for (let inspected = 0; inspected < TASK_WORK_LIMITS.retryInspections && attempts < TASK_WORK_LIMITS.retries; inspected++) {
528
752
  if (!options.store.hasPendingOutbox() || !work.canRetry()) break;
529
753
  // Advance one attempted/actually skipped ID, never an unattempted batch suffix.
530
- const record = options.store.selectPendingOutbox(1, Date.now(), 1)[0];
754
+ const record = options.store.selectPendingOutbox(1, timers.now(), 1)[0];
531
755
  if (!record) continue;
532
756
  const id = record.envelope.envelopeId;
533
757
  if (reportedEnvelopeIds?.has(id) || work.attemptedSends.has(id) || inFlight.has(id) || work.blockedRetryTargets.has(JSON.stringify(record.envelope.target))) continue;
@@ -544,6 +768,8 @@ function createOutboxFlusher(options: TaskCoreOptions): OutboxFlusher {
544
768
  }
545
769
 
546
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);
547
773
  throwIfAborted(signal);
548
774
  let abort!: () => void;
549
775
  const cancelled = new Promise<never>((_, reject) => { abort = () => reject(new TaskProtocolError("ABORTED", "task operation aborted", { details: { mayHaveBeenDelivered: true } })); signal.addEventListener("abort", abort, { once: true }); });
@@ -577,9 +803,15 @@ function parsePayload(envelope: RelayEnvelope): unknown {
577
803
  try { return JSON.parse(envelope.payload) as unknown; } catch { throw new TaskProtocolError("INVALID_PAYLOAD", "relay envelope payload is not valid task protocol JSON"); }
578
804
  }
579
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
+
580
812
  function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: string, envelopeIds: readonly string[]): void {
581
813
  const task = requiredTask(options.store, taskId);
582
- 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));
583
815
  if (blocked === undefined) return;
584
816
  throw new TaskOutboxDeliveryError(blocked.errorCode, blocked.reason, {
585
817
  retryable: false,
@@ -588,7 +820,7 @@ function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: st
588
820
  }
589
821
 
590
822
  function throwIfDeliveryEvidenceBlocked(store: TaskStore, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): void {
591
- const blocked = store.quarantinedOutbox().find((record) => envelopeIds.includes(record.envelope.envelopeId));
823
+ const blocked = firstQuarantined(store, envelopeIds);
592
824
  if (blocked === undefined) return;
593
825
  throw new TaskOutboxDeliveryError(blocked.errorCode, "task delivery evidence is blocked", {
594
826
  retryable: false,
@@ -621,10 +853,7 @@ function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
621
853
  }
622
854
 
623
855
  function statusFor(type: string): TaskRecord["status"] {
624
- if (type === "task.completed") return "completed";
625
- if (type === "task.failed") return "failed";
626
- if (type === "task.cancelled") return "cancelled";
627
- return "timed_out";
856
+ return TASK_EVENT_TYPES[type]?.status ?? "timed_out";
628
857
  }
629
858
 
630
859
  function isInboxPage(value: unknown): value is { readonly deliveries: readonly RelayDelivery[]; readonly nextCursor: string; readonly hasMore: boolean } {