@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/README.md +10 -4
- package/docs/protocol-reference.md +16 -7
- package/package.json +1 -1
- package/src/abortable-sleep.ts +14 -5
- package/src/configured-task-core.ts +8 -4
- package/src/extension.ts +144 -47
- package/src/index.ts +3 -2
- package/src/task-core.ts +349 -120
- package/src/task-errors.ts +111 -0
- package/src/task-inbox.ts +183 -96
- package/src/task-protocol.ts +44 -13
- package/src/task-store.ts +125 -22
- package/src/task-work-scope.ts +45 -10
- package/src/volatile-task-session.ts +30 -8
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
|
|
32
|
-
const TERMINAL_EVENTS =
|
|
33
|
-
const CONVERSATIONAL_EVENTS =
|
|
34
|
-
const CANONICAL_EVENTS =
|
|
35
|
-
|
|
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
|
|
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
|
|
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 >
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
165
|
-
if (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 =
|
|
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 =
|
|
207
|
-
async receive(signal, work =
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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 =
|
|
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
|
|
396
|
+
await ship(signal, persistedEnvelopeIds, work, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, persistedEnvelopeIds, evidence));
|
|
273
397
|
},
|
|
274
398
|
recordWakeRequest(input, work) {
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
},
|
|
407
|
+
}, operation);
|
|
284
408
|
});
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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 =
|
|
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
|
|
436
|
+
if (persistedEnvelopeIds.length > 0) await ship(signal, persistedEnvelopeIds, work);
|
|
302
437
|
},
|
|
303
|
-
async acknowledgeParent(taskId, signal, work =
|
|
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;
|
|
383
|
-
const
|
|
384
|
-
const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation,
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
const
|
|
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 (
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
678
|
+
return items.sort((left, right) => left.order - right.order);
|
|
472
679
|
}
|
|
473
680
|
|
|
474
|
-
|
|
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 >
|
|
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:
|
|
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.
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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 } {
|