@sgtbeatdown/pi-tasks 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/task-core.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  MAX_RELAY_PAYLOAD_BYTES,
3
+ INVALID_RELAY_METADATA,
3
4
  ORIGIN_CANCELLATION_OPERATION,
4
5
  PARENT_ACKNOWLEDGMENT_OPERATION,
5
6
  TASK_PROTOCOL_VERSION,
@@ -24,7 +25,7 @@ import type { TaskStore } from "./task-store";
24
25
 
25
26
  const RECEIVE_PAGE_SIZE = 100;
26
27
  const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
27
- const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
28
+ const TERMINAL_DELIVERY_CODES = new Set(["TARGET_NOT_REGISTERED", INVALID_RELAY_METADATA, "DELIVERY_UNCONFIRMED", "ENVELOPE_EXPIRED", "ENVELOPE_CONFLICT", "CROSS_RELAY_ENDPOINT"]);
28
29
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
29
30
  const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
30
31
  const CANONICAL_EVENTS = new Set([
@@ -84,6 +85,19 @@ type ReceivedEnvelope =
84
85
  export function createTaskCore(options: TaskCoreOptions): TaskCore {
85
86
  const clock = options.clock ?? { now: (): number => Date.now() };
86
87
  const ids = options.ids ?? (() => crypto.randomUUID());
88
+ const acknowledgeRelayDelivery = async (cursor: string, signal?: AbortSignal): Promise<void> => {
89
+ if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
90
+ const envelopeId = options.store.pendingRelayEnvelopeId(options.endpoint, cursor);
91
+ if (envelopeId === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
92
+ options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor));
93
+ await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor, envelopeId }, signal);
94
+ options.store.transaction(() => options.store.acknowledgeRelayCursor(options.endpoint, cursor));
95
+ };
96
+ const retryRequestedAcknowledgements = async (signal?: AbortSignal): Promise<void> => {
97
+ for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint)) {
98
+ if (options.store.pendingRelayEnvelopeId(options.endpoint, cursor) !== undefined) await acknowledgeRelayDelivery(cursor, signal);
99
+ }
100
+ };
87
101
  const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome> => {
88
102
  if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
89
103
  let persistedEnvelopeIds: readonly string[] = [];
@@ -121,6 +135,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
121
135
  const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
122
136
  if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
123
137
  throwIfAborted(signal);
138
+ await retryRequestedAcknowledgements(signal);
124
139
  },
125
140
  async createTask(input, signal) {
126
141
  if (input.task.length === 0 || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1) throw new TaskProtocolError("INVALID_TASK", "task and a positive timeout are required");
@@ -133,7 +148,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
133
148
  createdAt: now, expiresAt: now + input.timeoutMs, status: "active",
134
149
  };
135
150
  const created = event(task, ids(), "task.created", "1", options.endpoint, target, now, { task: input.task });
136
- const assignment = envelope(ids(), options.endpoint, target, taskId, TaskEnvelopeKind.assignment, { task, event: created });
151
+ const assignment = envelope(ids(), options.endpoint, target, taskId, TaskEnvelopeKind.assignment, { task, event: created }, now);
137
152
  options.store.transaction(() => {
138
153
  options.store.putTask(task);
139
154
  options.store.appendEvent(created);
@@ -150,12 +165,23 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
150
165
  listTasks() { return options.store.listTasks(); },
151
166
  async flushOutbox(signal) { await flush(options, signal); },
152
167
  async receive(signal) {
168
+ await retryRequestedAcknowledgements(signal);
153
169
  await flush(options, signal, IGNORE_OUTBOX_FAILURES);
154
- const page = await options.relay.receive({ endpoint: options.endpoint, cursor: options.store.getReceiveCursor(), limit: RECEIVE_PAGE_SIZE }, signal);
170
+ const cursor = options.store.getReceiveCursor();
171
+ const page = await options.relay.receive({ endpoint: options.endpoint, cursor, limit: RECEIVE_PAGE_SIZE }, signal);
155
172
  if (!isInboxPage(page)) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
173
+ let previous = BigInt(cursor);
174
+ for (const delivery of page.deliveries) {
175
+ 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");
176
+ previous = BigInt(delivery.cursor);
177
+ }
178
+ if (BigInt(page.nextCursor) < previous) throw new TaskProtocolError("INVALID_INBOX", "relay page cursor precedes its deliveries");
179
+ // Track the entire observed prefix before an intent can ACK a later
180
+ // delivery. Keep only pending IDs plus a high-water mark, durably.
181
+ options.store.transaction(() => options.store.trackRelayDeliveries(options.endpoint, page.deliveries.map(delivery => ({ cursor: delivery.cursor, envelopeId: delivery.envelope.envelopeId }))));
156
182
  const visibleDeliveries: RelayDelivery[] = [];
157
183
  for (const delivery of page.deliveries) {
158
- if (!isDelivery(delivery)) throw new TaskProtocolError("INVALID_DELIVERY", "relay returned an invalid delivery");
184
+ if (options.store.pendingRelayEnvelopeId(options.endpoint, delivery.cursor) === undefined) continue;
159
185
  const received = validateReceivedEnvelope(options, delivery.envelope);
160
186
  let persistedEnvelopeIds: readonly string[] = [];
161
187
  options.store.transaction(() => {
@@ -171,11 +197,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
171
197
  }
172
198
  return visibleDeliveries;
173
199
  },
174
- async acknowledgeRelayDelivery(cursor, signal) {
175
- if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
176
- await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor }, signal);
177
- options.store.transaction(() => { options.store.setReceiveCursor(cursor); });
178
- },
200
+ acknowledgeRelayDelivery,
179
201
  async submitIntent(input, signal) { await submitIntentWithOutcome(input, signal); },
180
202
  submitIntentWithOutcome,
181
203
  async recordInsertion(input, signal) {
@@ -279,6 +301,7 @@ interface PersistedIntent {
279
301
  }
280
302
 
281
303
  function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
304
+ 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 });
282
305
  if (sameEndpoint(options.endpoint, task.origin)) {
283
306
  const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
284
307
  const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
@@ -306,7 +329,7 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
306
329
  }
307
330
  }
308
331
  options.store.putIntent(intent.intentId, input.taskId, envelopeId);
309
- options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
332
+ options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent, now()));
310
333
  return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
311
334
  }
312
335
 
@@ -324,7 +347,7 @@ function deliveryEvidence(input: SubmitIntentInput): DeliveryEvidence | undefine
324
347
  if (stage === TaskDeliveryStage.piInsertion && state === TaskDeliveryEvidenceState.blocked && input.payload.retryable === true) {
325
348
  return { eventId: input.payload.eventId, stage, state, retryable: true };
326
349
  }
327
- if ((stage === TaskDeliveryStage.receiverPersisted || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
350
+ if ((stage === TaskDeliveryStage.receiverRecorded || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
328
351
  && state === TaskDeliveryEvidenceState.confirmed) {
329
352
  return { eventId: input.payload.eventId, stage, state };
330
353
  }
@@ -346,8 +369,8 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
346
369
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
347
370
  const sequence = String(task.events.length + 1);
348
371
  const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
349
- const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
350
- const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
372
+ const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
373
+ const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
351
374
  const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
352
375
  if (operation !== undefined) {
353
376
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
@@ -378,7 +401,7 @@ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined,
378
401
  options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
379
402
  } catch (error) {
380
403
  if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
381
- if (error instanceof TaskProtocolError && error.code === TARGET_NOT_REGISTERED_CODE && !error.retryable) {
404
+ if (error instanceof TaskProtocolError && TERMINAL_DELIVERY_CODES.has(error.code) && !error.retryable) {
382
405
  options.store.transaction(() => {
383
406
  options.store.quarantineOutbox(record.envelope.envelopeId, {
384
407
  errorCode: error.code,
@@ -413,8 +436,8 @@ function event(task: Omit<TaskRecord, "events">, eventId: string, type: string,
413
436
  return { eventId, taskId: task.taskId, type, sequence, source, target, occurredAt, payload };
414
437
  }
415
438
 
416
- function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown): RelayEnvelope {
417
- return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload) };
439
+ function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown, createdAt: number): RelayEnvelope {
440
+ return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload), createdAt: new Date(createdAt).toISOString() };
418
441
  }
419
442
 
420
443
  function parsePayload(envelope: RelayEnvelope): unknown {
@@ -460,7 +483,7 @@ function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void
460
483
 
461
484
  function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
462
485
  const task = store.getTask(taskId);
463
- if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`);
486
+ if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`, { retryable: false });
464
487
  return task;
465
488
  }
466
489
 
package/src/task-inbox.ts CHANGED
@@ -5,6 +5,7 @@ import type { RelayDelivery, TaskEvent } from "./task-protocol";
5
5
  const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
6
6
  const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
7
7
  const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
8
+ const TASK_RECORD_CUSTOM_TYPE = "pi-tasks-event-record";
8
9
 
9
10
  interface InboxContext {
10
11
  readonly isIdle: () => boolean;
@@ -17,16 +18,18 @@ interface InboxPi {
17
18
  message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails },
18
19
  options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
19
20
  ): void;
20
- appendEntry(customType: string, data: { readonly cursor: string }): void;
21
+ appendEntry(customType: string, data: { readonly cursor: string } | TaskEventDetails): void;
21
22
  }
22
23
 
23
24
  export interface TaskEventDetails {
24
25
  readonly taskId: string;
25
26
  readonly eventId: string;
27
+ /** Historical evidence only; never used to rebuild active task state. */
28
+ readonly event?: TaskEvent;
26
29
  }
27
30
 
28
31
  type DeliveryEvidencePayload =
29
- | { readonly stage: typeof TaskDeliveryStage.receiverPersisted | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
32
+ | { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
30
33
  | { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
31
34
 
32
35
  /** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
@@ -44,8 +47,13 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
44
47
  if (context.hasPendingMessages()) return;
45
48
  const eventDetails = { taskId: event.taskId, eventId: event.eventId };
46
49
  const eventKey = key(event.taskId, event.eventId);
50
+ if (!isModelVisible(event.type) && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
51
+ // Archive receipts/parent ACK/late-terminal facts without waking the model.
52
+ // This is evidence only: no task state or delivery queue is reconstructed.
53
+ pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
54
+ }
47
55
  if (event.type === "task.created") {
48
- await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverPersisted, state: TaskDeliveryEvidenceState.confirmed }, signal);
56
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
49
57
  }
50
58
  let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
51
59
  if (!incorporated && isModelVisible(event.type)) {
@@ -54,7 +62,7 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
54
62
  customType: TASK_EVENT_CUSTOM_TYPE,
55
63
  content: renderTaskEvent(event),
56
64
  display: true,
57
- details: eventDetails,
65
+ details: { ...eventDetails, event },
58
66
  }, { triggerTurn: false });
59
67
  incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
60
68
  if (!incorporated) {
@@ -126,6 +134,12 @@ function taskMessageDetails(entries: readonly unknown[], customType: string): re
126
134
  return events;
127
135
  }
128
136
 
137
+ function recordedEventKeys(entries: readonly unknown[]): Set<string> {
138
+ return new Set(entries.flatMap(entry => isRecord(entry) && entry.type === "custom" && entry.customType === TASK_RECORD_CUSTOM_TYPE
139
+ && isRecord(entry.data) && typeof entry.data.taskId === "string" && typeof entry.data.eventId === "string"
140
+ ? [key(entry.data.taskId, entry.data.eventId)] : []));
141
+ }
142
+
129
143
  function taskMessageKeys(entries: readonly unknown[], customType: string): Set<string> {
130
144
  return new Set(taskMessageDetails(entries, customType).map((event) => key(event.taskId, event.eventId)));
131
145
  }
@@ -1,11 +1,12 @@
1
1
  export const TASK_PROTOCOL_VERSION = "pi-tasks/v2" as const;
2
2
  export const MAX_RELAY_PAYLOAD_BYTES = 48 * 1024;
3
+ export const INVALID_RELAY_METADATA = "INVALID_RELAY_METADATA";
3
4
  export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
4
5
  export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
5
6
  export const TERMINAL_INTENT_OPERATION = "terminal_intent";
6
7
 
7
8
  export const TaskDeliveryStage = {
8
- receiverPersisted: "receiver_persisted",
9
+ receiverRecorded: "receiver_recorded",
9
10
  piInsertion: "pi_insertion",
10
11
  piInserted: "pi_inserted",
11
12
  wakeRequested: "wake_requested",
@@ -22,6 +23,17 @@ export interface TaskEndpoint {
22
23
  readonly id: string;
23
24
  }
24
25
 
26
+ /** Endpoint-owned transport binding; not task lifecycle or relay replay state. */
27
+ export interface RelayTransportBinding {
28
+ readonly profile: "volatile-v1";
29
+ readonly epoch: string;
30
+ readonly endpoint: TaskEndpoint;
31
+ readonly generation: string;
32
+ readonly callerSession: string;
33
+ readonly url: string;
34
+ readonly reset?: true;
35
+ }
36
+
25
37
  /** A provider-defined reference which core resolves without interpreting. */
26
38
  export interface RelayTargetReference {
27
39
  readonly relay: string;
@@ -54,6 +66,9 @@ export interface RelayEnvelope {
54
66
  readonly target: TaskEndpoint;
55
67
  readonly taskId: string;
56
68
  readonly kind: TaskEnvelopeKind;
69
+ /** Immutable transport creation time, persisted with new outbox envelopes.
70
+ * Received/legacy records may omit it; Wolfpack sends require it. */
71
+ readonly createdAt?: string;
57
72
  /** Opaque to relays. The endpoint protocol defines its JSON representation. */
58
73
  readonly payload: string;
59
74
  }
@@ -82,6 +97,8 @@ export interface RelayInboxPage {
82
97
  export interface RelayDeliveryAck {
83
98
  readonly endpoint: TaskEndpoint;
84
99
  readonly cursor: string;
100
+ /** Durable cursor-to-envelope binding, for identical ACK retry after reopen. */
101
+ readonly envelopeId?: string;
85
102
  }
86
103
 
87
104
  export interface TaskRelay {
@@ -90,6 +107,7 @@ export interface TaskRelay {
90
107
  resolve(input: RelayTargetReference, signal?: AbortSignal): Promise<TaskEndpoint>;
91
108
  send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance>;
92
109
  receive(input: RelayReceiveRequest, signal?: AbortSignal): Promise<RelayInboxPage>;
110
+ /** Acknowledge this delivery only, never an implicit cursor prefix. */
93
111
  acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void>;
94
112
  }
95
113
 
@@ -5,6 +5,16 @@ import type { TaskRelay } from "./task-protocol";
5
5
 
6
6
  /** Shared assertions external relay adapters can run against their implementation. */
7
7
  export function runTaskRelayConformance(createRelay: () => TaskRelay): void {
8
+ test("acknowledging a later delivery retains earlier unacknowledged mail", async () => {
9
+ const relay = createRelay();
10
+ const source = { relay: relay.id, id: "source" }, target = { relay: relay.id, id: "target" };
11
+ for (const endpoint of [source, target]) await relay.connect({ endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
12
+ for (const envelopeId of ["first", "second"]) await relay.send({ envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task", kind: "assignment", payload: "{}", createdAt: new Date().toISOString() });
13
+ const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });
14
+ expect(page.deliveries).toHaveLength(2);
15
+ await relay.acknowledgeDelivery({ endpoint: target, cursor: page.deliveries[1]!.cursor });
16
+ expect((await relay.receive({ endpoint: target, cursor: "0", limit: 10 })).deliveries.map(d => d.envelope.envelopeId)).toContain("first");
17
+ });
8
18
  test("enforces registration, accepts duplicate envelopes once, and retains delivery until acknowledgement", async () => {
9
19
  const relay = createRelay();
10
20
  const source = { relay: relay.id, id: "source" };
@@ -12,7 +22,7 @@ export function runTaskRelayConformance(createRelay: () => TaskRelay): void {
12
22
  await relay.connect({ endpoint: source, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
13
23
  await relay.connect({ endpoint: target, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
14
24
  await expect(relay.resolve({ relay: relay.id, reference: "missing" })).rejects.toThrow("not registered");
15
- const envelope = { envelopeId: "envelope-1", protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task-1", kind: "assignment" as const, payload: "{}" };
25
+ const envelope = { envelopeId: "envelope-1", protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task-1", kind: "assignment" as const, payload: "{}", createdAt: new Date(0).toISOString() };
16
26
  await relay.send(envelope);
17
27
  await relay.send(envelope);
18
28
  const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });