@sgtbeatdown/pi-tasks 0.1.13 → 0.1.15

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
@@ -21,10 +21,13 @@ import type {
21
21
  TaskRelay,
22
22
  TaskSnapshot,
23
23
  } from "./task-protocol";
24
- import type { TaskStore } from "./task-store";
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";
25
27
 
26
- const RECEIVE_PAGE_SIZE = 100;
28
+ const RECEIVE_PAGE_SIZE = TASK_WORK_LIMITS.pageItems;
27
29
  const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
30
+ const RECEIVED_INTENT_OPERATION = "received_intent";
28
31
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
29
32
  const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
30
33
  const CONVERSATIONAL_EVENTS = new Set(["task.information", "task.question", "task.answer"]);
@@ -68,19 +71,21 @@ export interface WakeRequest {
68
71
 
69
72
  export interface TaskCore {
70
73
  readonly endpoint: TaskEndpoint;
71
- connect(signal?: AbortSignal): Promise<void>;
72
- createTask(input: CreateTaskInput, signal?: AbortSignal): Promise<{ readonly taskId: string }>;
74
+ connect(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
75
+ createTask(input: CreateTaskInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<{ readonly taskId: string }>;
73
76
  getTask(taskId: string): TaskSnapshot | undefined;
74
77
  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>;
78
+ /** Correlate a receiver conversation with a locally recorded outbound intent, never session history or payload author claims. */
79
+ isSelfAuthoredConversation(event: TaskEvent): boolean;
80
+ flushOutbox(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
81
+ receive(signal?: AbortSignal, work?: TaskWorkScope): Promise<readonly RelayDelivery[]>;
82
+ acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
83
+ submitIntent(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
84
+ submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<SubmitIntentOutcome>;
85
+ recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
86
+ recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }, work?: TaskWorkScope): WakeRequest;
87
+ evaluateTimeouts(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
88
+ acknowledgeParent(taskId: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
84
89
  }
85
90
 
86
91
  type ReceivedEnvelope =
@@ -91,20 +96,34 @@ type ReceivedEnvelope =
91
96
  export function createTaskCore(options: TaskCoreOptions): TaskCore {
92
97
  const clock = options.clock ?? { now: (): number => Date.now() };
93
98
  const ids = options.ids ?? (() => crypto.randomUUID());
94
- const acknowledgeRelayDelivery = async (cursor: string, signal?: AbortSignal): Promise<void> => {
99
+ const flush = createOutboxFlusher(options);
100
+ const acknowledgeWithSignal = async (cursor: string, signal: AbortSignal, work: TaskWorkScope): Promise<void> => {
95
101
  if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
96
102
  const envelopeId = options.store.pendingRelayEnvelopeId(options.endpoint, cursor);
97
103
  if (envelopeId === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
104
+ 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()) });
98
106
  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));
107
+ try {
108
+ await waitForRelayOperation(options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor, envelopeId }, signal), signal);
109
+ throwIfAborted(signal);
110
+ options.store.transaction(() => options.store.acknowledgeRelayCursor(options.endpoint, cursor));
111
+ } catch (error) {
112
+ if (signal.aborted) throw error;
113
+ const retryAt = capacityRetryAt(error);
114
+ if (retryAt !== undefined) options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor, retryAt));
115
+ throw error;
116
+ }
101
117
  };
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);
118
+ const acknowledgeRelayDelivery = (cursor: string, signal?: AbortSignal, work = createTaskWorkScope(signal)): Promise<void> => acknowledgeWithSignal(cursor, work.signal, work);
119
+ const retryRequestedAcknowledgements = async (work: TaskWorkScope): Promise<void> => {
120
+ for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint, TASK_WORK_LIMITS.acknowledgements, Date.now())) {
121
+ if (!work.canAcknowledge() || !work.canRepairAcknowledgement()) break;
122
+ if (!work.attemptedAcknowledgements.has(cursor) && options.store.pendingRelayEnvelopeId(options.endpoint, cursor) !== undefined) await acknowledgeWithSignal(cursor, work.backgroundSignal(), work);
105
123
  }
106
124
  };
107
- const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome> => {
125
+ const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal, work = createTaskWorkScope(signal, "foreground")): Promise<SubmitIntentOutcome> => {
126
+ signal = work.signal;
108
127
  if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
109
128
  let persistedEnvelopeIds: readonly string[] = [];
110
129
  let outcome: SubmitIntentOutcome | undefined;
@@ -120,10 +139,10 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
120
139
  outcome = persisted.outcome;
121
140
  });
122
141
  if (evidence !== undefined) {
123
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
142
+ await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence, work, flush);
124
143
  } else {
125
144
  try {
126
- await flush(options, signal, new Set(persistedEnvelopeIds));
145
+ await flush(signal, new Set(persistedEnvelopeIds), work);
127
146
  } catch (error) {
128
147
  if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
129
148
  throw error;
@@ -137,15 +156,19 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
137
156
 
138
157
  return {
139
158
  endpoint: options.endpoint,
140
- async connect(signal) {
159
+ async connect(signal, work = createTaskWorkScope(signal)) {
160
+ signal = work.signal;
141
161
  const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
142
162
  if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
143
163
  throwIfAborted(signal);
144
- await retryRequestedAcknowledgements(signal);
164
+ // A reserved refresh repairs only after the initial page has been intaken.
165
+ if (work.retryAllowed) await retryRequestedAcknowledgements(work);
145
166
  },
146
- async createTask(input, signal) {
167
+ async createTask(input, signal, work = createTaskWorkScope(signal, "foreground")) {
168
+ signal = work.signal;
147
169
  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
170
  const target = await options.relay.resolve({ relay: input.target.relay, reference: input.target.id }, signal);
171
+ throwIfAborted(signal);
149
172
  if (!isEndpoint(target)) throw new TaskProtocolError("INVALID_TARGET", "relay resolved an invalid task endpoint");
150
173
  const now = clock.now();
151
174
  const taskId = ids();
@@ -161,7 +184,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
161
184
  options.store.putOutbox(assignment);
162
185
  });
163
186
  try {
164
- await flush(options, signal, new Set([assignment.envelopeId]));
187
+ await flush(signal, new Set([assignment.envelopeId]), work);
165
188
  } catch (error) {
166
189
  throw operationError(error, taskId);
167
190
  }
@@ -169,44 +192,72 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
169
192
  },
170
193
  getTask(taskId) { return options.store.getTask(taskId); },
171
194
  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;
195
+ isSelfAuthoredConversation(event) {
196
+ if (!CONVERSATIONAL_EVENTS.has(event.type) || typeof event.payload.intentId !== "string") return false;
197
+ const task = options.store.getTask(event.taskId);
198
+ if (!task || !sameEndpoint(options.endpoint, task.target) || sameEndpoint(options.endpoint, task.origin)
199
+ || !sameEndpoint(event.source, task.origin) || !sameEndpoint(event.target, task.target)) return false;
200
+ const localEnvelope = options.store.getIntentEnvelope(event.payload.intentId);
201
+ if (!localEnvelope || localEnvelope.kind !== TaskEnvelopeKind.intent || localEnvelope.taskId !== task.taskId
202
+ || !sameEndpoint(localEnvelope.source, options.endpoint) || !sameEndpoint(localEnvelope.target, task.origin)) return false;
203
+ const intent = parsePayload(localEnvelope);
204
+ return isTaskIntent(intent) && intent.taskId === event.taskId && intent.type === event.type && intent.intentId === event.payload.intentId;
205
+ },
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.
211
+ 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);
201
243
  }
202
- visibleDeliveries.push(delivery);
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
+ } 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);
203
254
  }
204
- return visibleDeliveries;
205
255
  },
206
256
  acknowledgeRelayDelivery,
207
- async submitIntent(input, signal) { await submitIntentWithOutcome(input, signal); },
257
+ async submitIntent(input, signal, work) { await submitIntentWithOutcome(input, signal, work); },
208
258
  submitIntentWithOutcome,
209
- async recordInsertion(input, signal) {
259
+ async recordInsertion(input, signal, work = createTaskWorkScope(signal)) {
260
+ signal = work.signal;
210
261
  const task = requiredTask(options.store, input.taskId);
211
262
  const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.piInserted, state: TaskDeliveryEvidenceState.confirmed };
212
263
  let persistedEnvelopeIds: readonly string[] = [];
@@ -218,9 +269,9 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
218
269
  payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
219
270
  }, deliveryEvidenceOperation(evidence)).envelopeIds;
220
271
  });
221
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
272
+ await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence, work, flush);
222
273
  },
223
- recordWakeRequest(input) {
274
+ recordWakeRequest(input, work) {
224
275
  const task = requiredTask(options.store, input.taskId);
225
276
  const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed };
226
277
  let persistedEnvelopeIds: readonly string[] = [];
@@ -232,10 +283,12 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
232
283
  }, deliveryEvidenceOperation(evidence)).envelopeIds;
233
284
  });
234
285
  return { flush: async (signal?: AbortSignal): Promise<void> => {
235
- await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
286
+ const activeWork = work ?? createTaskWorkScope(signal);
287
+ await flushDeliveryEvidence(options, activeWork.signal, input.taskId, persistedEnvelopeIds, evidence, activeWork, flush);
236
288
  } };
237
289
  },
238
- async evaluateTimeouts(signal) {
290
+ async evaluateTimeouts(signal, work = createTaskWorkScope(signal)) {
291
+ signal = work.signal;
239
292
  const persistedEnvelopeIds: string[] = [];
240
293
  for (const candidate of options.store.listTasks()) {
241
294
  if (!sameEndpoint(candidate.origin, options.endpoint) || TERMINAL_STATUSES.has(candidate.status) || candidate.expiresAt > clock.now()) continue;
@@ -245,9 +298,10 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
245
298
  persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out").envelopeIds);
246
299
  });
247
300
  }
248
- if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
301
+ if (persistedEnvelopeIds.length > 0) await flush(signal, new Set(persistedEnvelopeIds), work);
249
302
  },
250
- async acknowledgeParent(taskId, signal) {
303
+ async acknowledgeParent(taskId, signal, work = createTaskWorkScope(signal)) {
304
+ signal = work.signal;
251
305
  let persistedEnvelopeIds: readonly string[] = [];
252
306
  options.store.transaction(() => {
253
307
  const task = requiredTask(options.store, taskId);
@@ -255,7 +309,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
255
309
  if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
256
310
  persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION).envelopeIds;
257
311
  });
258
- await flush(options, signal, new Set(persistedEnvelopeIds));
312
+ await flush(signal, new Set(persistedEnvelopeIds), work);
259
313
  },
260
314
  };
261
315
  }
@@ -305,13 +359,13 @@ function validateCanonicalEvent(options: TaskCoreOptions, envelope: RelayEnvelop
305
359
  return { kind: "canonical_event", task, event: payload };
306
360
  }
307
361
 
308
- function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope): readonly string[] {
362
+ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope, envelopeId: string): readonly string[] {
309
363
  if (received.kind === "assignment") {
310
364
  options.store.putTask(received.task);
311
365
  options.store.appendEvent(received.event);
312
366
  return [];
313
367
  }
314
- if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type).envelopeIds;
368
+ if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type, `${RECEIVED_INTENT_OPERATION}:${envelopeId}`).envelopeIds;
315
369
  if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
316
370
  return [];
317
371
  }
@@ -391,7 +445,7 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
391
445
  const terminal = TERMINAL_EVENTS.has(requestedType);
392
446
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
393
447
  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 });
448
+ const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { ...intent.payload, intentId: intent.intentId });
395
449
  const suppressSelfTargetConversationEcho = !echoCanonicalEventToOrigin && sameEndpoint(task.origin, task.target);
396
450
  const targetEnvelope = suppressSelfTargetConversationEcho ? undefined : envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
397
451
  const originEnvelope = !echoCanonicalEventToOrigin || sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
@@ -407,9 +461,9 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
407
461
  return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
408
462
  }
409
463
 
410
- async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSignal | undefined, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): Promise<void> {
464
+ async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSignal | undefined, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence, work: TaskWorkScope, flush: OutboxFlusher): Promise<void> {
411
465
  try {
412
- if (envelopeIds.length > 0) await flush(options, signal, new Set(envelopeIds));
466
+ if (envelopeIds.length > 0) await flush(signal, new Set(envelopeIds), work);
413
467
  } catch (error) {
414
468
  throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
415
469
  throw error;
@@ -417,29 +471,84 @@ async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSign
417
471
  throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
418
472
  }
419
473
 
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
- });
474
+ type OutboxFlusher = (signal: AbortSignal | undefined, reportedEnvelopeIds: ReadonlySet<string> | undefined, work: TaskWorkScope) => Promise<void>;
475
+
476
+ function createOutboxFlusher(options: TaskCoreOptions): OutboxFlusher {
477
+ const inFlight = new Map<string, Promise<void>>();
478
+ const send = async (record: OutboxRecord, retry: boolean, work: TaskWorkScope): Promise<void> => {
479
+ const id = record.envelope.envelopeId;
480
+ const joined = inFlight.get(id);
481
+ if (joined) { await waitForRelayOperation(joined, work.signal); return; }
482
+ 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()) });
484
+ const signal = retry ? work.backgroundSignal() : work.signal;
485
+ const delivery = (async (): Promise<void> => {
486
+ try {
487
+ await waitForRelayOperation(options.relay.send(record.envelope, signal), signal);
488
+ throwIfAborted(signal);
489
+ options.store.transaction(() => options.store.markOutboxAccepted(id));
490
+ } catch (error) {
491
+ if (signal.aborted) throw error;
492
+ const retryAt = capacityRetryAt(error);
493
+ if (retryAt !== undefined) {
494
+ options.store.postponeOutbox(id, retryAt);
495
+ // A large foreground refusal does not prove smaller old mail cannot fit.
496
+ if (retry) work.blockedRetryTargets.add(JSON.stringify(record.envelope.target));
497
+ }
498
+ 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() });
436
500
  });
501
+ throw error;
502
+ }
503
+ })();
504
+ inFlight.set(id, delivery);
505
+ try { await delivery; } finally { if (inFlight.get(id) === delivery) inFlight.delete(id); }
506
+ };
507
+ return async (signal, reportedEnvelopeIds, work) => {
508
+ throwIfAborted(signal);
509
+ let failure: unknown;
510
+ if (reportedEnvelopeIds) for (const id of reportedEnvelopeIds) {
511
+ const record = options.store.getOutboxRecord(id);
512
+ if (record?.state === "accepted") continue;
513
+ if (!record) {
514
+ const quarantined = options.store.quarantinedOutbox().find(item => item.envelope.envelopeId === id);
515
+ failure ??= quarantined ? new TaskProtocolError(quarantined.errorCode, quarantined.reason, { retryable: false, details: quarantined.details }) : deferredTaskDelivery({ envelopeId: id });
516
+ continue;
517
+ }
518
+ try { await send(record, false, work); } catch (error) {
519
+ if (work.signal.aborted) throw error;
520
+ failure ??= error;
521
+ if (error instanceof TaskProtocolError && error.code === "TASK_DELIVERY_DEFERRED") break;
437
522
  }
438
- failures.push({ envelopeId: record.envelope.envelopeId, error });
439
523
  }
440
- }
441
- const reportedFailure = failures.find((failure) => reportedEnvelopeIds === undefined || reportedEnvelopeIds.has(failure.envelopeId));
442
- if (reportedFailure) throw outboxDeliveryError(reportedFailure.error);
524
+ if (work.retryAllowed && !work.retryStarted) {
525
+ work.retryStarted = true;
526
+ let attempts = 0;
527
+ for (let inspected = 0; inspected < TASK_WORK_LIMITS.retryInspections && attempts < TASK_WORK_LIMITS.retries; inspected++) {
528
+ if (!options.store.hasPendingOutbox() || !work.canRetry()) break;
529
+ // Advance one attempted/actually skipped ID, never an unattempted batch suffix.
530
+ const record = options.store.selectPendingOutbox(1, Date.now(), 1)[0];
531
+ if (!record) continue;
532
+ const id = record.envelope.envelopeId;
533
+ if (reportedEnvelopeIds?.has(id) || work.attemptedSends.has(id) || inFlight.has(id) || work.blockedRetryTargets.has(JSON.stringify(record.envelope.target))) continue;
534
+ attempts++;
535
+ try { await send(record, true, work); } catch (error) {
536
+ if (work.signal.aborted) throw error;
537
+ if (reportedEnvelopeIds === undefined) failure ??= error;
538
+ }
539
+ }
540
+ }
541
+ if (failure === undefined && reportedEnvelopeIds === undefined && options.store.hasPendingOutbox()) failure = deferredTaskDelivery();
542
+ if (failure !== undefined) throw outboxDeliveryError(failure);
543
+ };
544
+ }
545
+
546
+ async function waitForRelayOperation(delivery: Promise<unknown>, signal: AbortSignal): Promise<void> {
547
+ throwIfAborted(signal);
548
+ let abort!: () => void;
549
+ const cancelled = new Promise<never>((_, reject) => { abort = () => reject(new TaskProtocolError("ABORTED", "task operation aborted", { details: { mayHaveBeenDelivered: true } })); signal.addEventListener("abort", abort, { once: true }); });
550
+ try { await Promise.race([delivery, cancelled]); throwIfAborted(signal); }
551
+ finally { signal.removeEventListener("abort", abort); }
443
552
  }
444
553
 
445
554
  function outboxDeliveryError(error: unknown): TaskOutboxDeliveryError {
package/src/task-inbox.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { TaskCore } from "./task-core";
2
+ import { createTaskWorkScope } from "./task-work-scope";
3
+ import type { TaskWorkScope } from "./task-work-scope";
2
4
  import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
3
5
  import type { RelayDelivery, TaskEvent } from "./task-protocol";
4
6
 
@@ -34,79 +36,90 @@ type DeliveryEvidencePayload =
34
36
  | { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
35
37
 
36
38
  /** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
37
- export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<TaskOutboxDeliveryError | undefined> {
38
- if (context.hasPendingMessages()) return undefined;
39
- const deliveries = await core.receive(signal);
39
+ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal, work = createTaskWorkScope(signal)): Promise<TaskOutboxDeliveryError | undefined> {
40
+ signal = work.signal;
41
+ work.retryAllowed = false; // Reserve intake, presentation and evidence/ACK ahead of old work.
40
42
  let outboxError: TaskOutboxDeliveryError | undefined;
41
- for (const delivery of deliveries) {
42
- if (delivery.envelope.kind === TaskEnvelopeKind.intent) {
43
- await core.acknowledgeRelayDelivery(delivery.cursor, signal);
44
- pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
45
- continue;
46
- }
47
- const event = inboxEvent(delivery);
48
- if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
49
- if (context.hasPendingMessages()) return outboxError;
50
- const eventDetails = { taskId: event.taskId, eventId: event.eventId };
51
- const eventKey = key(event.taskId, event.eventId);
52
- if (!isModelVisible(event.type) && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
53
- // Archive receipts/parent ACK/late-terminal facts without waking the model.
54
- // This is evidence only: no task state or delivery queue is reconstructed.
55
- pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
56
- }
57
- if (event.type === "task.created") {
58
- const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
59
- outboxError ??= evidenceError;
60
- }
61
- let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
62
- if (!incorporated && isModelVisible(event.type)) {
63
- if (!context.isIdle()) return outboxError;
64
- pi.sendMessage({
65
- customType: TASK_EVENT_CUSTOM_TYPE,
66
- content: renderTaskEvent(event),
67
- display: true,
68
- details: { ...eventDetails, event },
69
- }, { triggerTurn: false });
70
- incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
71
- if (!incorporated) {
72
- const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
43
+ try {
44
+ const deliveries = await core.receive(signal, work);
45
+ for (const delivery of deliveries) {
46
+ if (delivery.envelope.kind === TaskEnvelopeKind.intent) {
47
+ await core.acknowledgeRelayDelivery(delivery.cursor, signal, work);
48
+ pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
49
+ continue;
50
+ }
51
+ const event = inboxEvent(delivery);
52
+ if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
53
+ if (context.hasPendingMessages()) break;
54
+ const eventDetails = { taskId: event.taskId, eventId: event.eventId };
55
+ const eventKey = key(event.taskId, event.eventId);
56
+ const modelVisible = isModelVisible(event.type) && !core.isSelfAuthoredConversation(event);
57
+ if (!modelVisible && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
58
+ // Archive control facts and confirmed self-authored conversations without model insertion or wake.
59
+ // This is evidence only: no task state or delivery queue is reconstructed.
60
+ pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
61
+ }
62
+ if (event.type === "task.created") {
63
+ const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal, work);
73
64
  outboxError ??= evidenceError;
74
- return outboxError;
75
65
  }
76
- }
77
- if (isModelVisible(event.type)) {
78
- const insertionError = await settleDeliveryEvidence(() => core.recordInsertion(eventDetails, signal));
79
- outboxError ??= insertionError;
80
- let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
81
- const wakeSuppressed = !wakeAccepted && hasAcknowledgedLocalOriginTerminal(core, event);
82
- if (!wakeAccepted && !wakeSuppressed) {
83
- if (!context.isIdle()) return outboxError;
84
- // This synchronous local write closes the ACK/wake race; do not await relay I/O before Pi emits the wake.
85
- const wakeRequest = core.recordWakeRequest(eventDetails);
66
+ let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
67
+ if (!incorporated && modelVisible) {
68
+ if (!context.isIdle()) break;
86
69
  pi.sendMessage({
87
- customType: TASK_WAKE_CUSTOM_TYPE,
88
- content: "Process the pending Pi task event.",
89
- display: false,
90
- details: eventDetails,
91
- }, { triggerTurn: true, deliverAs: "followUp" });
92
- wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
93
- if (!wakeAccepted) return outboxError;
94
- const wakeRequestError = await settleDeliveryEvidence(() => wakeRequest.flush(signal));
95
- outboxError ??= wakeRequestError;
70
+ customType: TASK_EVENT_CUSTOM_TYPE,
71
+ content: renderTaskEvent(event),
72
+ display: true,
73
+ details: { ...eventDetails, event },
74
+ }, { triggerTurn: false });
75
+ incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
76
+ if (!incorporated) {
77
+ const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal, work);
78
+ outboxError ??= evidenceError;
79
+ break;
80
+ }
96
81
  }
97
- if (wakeAccepted) {
98
- const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
99
- outboxError ??= wakeAcceptanceError;
82
+ if (modelVisible) {
83
+ const insertionError = await settleDeliveryEvidence(() => core.recordInsertion(eventDetails, signal, work));
84
+ outboxError ??= insertionError;
85
+ let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
86
+ const wakeSuppressed = !wakeAccepted && hasAcknowledgedLocalOriginTerminal(core, event);
87
+ if (!wakeAccepted && !wakeSuppressed) {
88
+ if (!context.isIdle()) break;
89
+ // This synchronous local write closes the ACK/wake race; do not await relay I/O before Pi emits the wake.
90
+ const wakeRequest = core.recordWakeRequest(eventDetails, work);
91
+ pi.sendMessage({
92
+ customType: TASK_WAKE_CUSTOM_TYPE,
93
+ content: "Process the pending Pi task event.",
94
+ display: false,
95
+ details: eventDetails,
96
+ }, { triggerTurn: true, deliverAs: "followUp" });
97
+ wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
98
+ if (!wakeAccepted) break;
99
+ const wakeRequestError = await settleDeliveryEvidence(() => wakeRequest.flush(signal));
100
+ outboxError ??= wakeRequestError;
101
+ }
102
+ if (wakeAccepted) {
103
+ const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal, work);
104
+ outboxError ??= wakeAcceptanceError;
105
+ }
100
106
  }
107
+ await core.acknowledgeRelayDelivery(delivery.cursor, signal, work);
108
+ pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
109
+ }
110
+ } finally {
111
+ // Completed, held or failed presentation yields the remaining bounded share to old work.
112
+ work.retryAllowed = true;
113
+ if (!work.signal.aborted) try { await core.flushOutbox(work.signal, work); } catch (error) {
114
+ if (!(error instanceof TaskOutboxDeliveryError)) throw error;
115
+ outboxError ??= error;
101
116
  }
102
- await core.acknowledgeRelayDelivery(delivery.cursor, signal);
103
- pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
104
117
  }
105
118
  return outboxError;
106
119
  }
107
120
 
108
- async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<TaskOutboxDeliveryError | undefined> {
109
- return settleDeliveryEvidence(() => core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal));
121
+ async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal: AbortSignal, work: TaskWorkScope): Promise<TaskOutboxDeliveryError | undefined> {
122
+ return settleDeliveryEvidence(() => core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal, work));
110
123
  }
111
124
 
112
125
  async function settleDeliveryEvidence(operation: () => Promise<void>): Promise<TaskOutboxDeliveryError | undefined> {