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