@sgtbeatdown/pi-tasks 0.1.8 → 0.1.9

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 CHANGED
@@ -24,6 +24,87 @@ The `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`.
24
24
 
25
25
  Wolfpack owns durable mailbox delivery and peer forwarding; Pi owns lifecycle, logical event order, receipts, and local SQLite state. `agent_task_send` returns after relay acceptance only, not Pi insertion or model execution. The receiver inserts model-visible events as structured `pi-tasks-event` custom messages through Pi's safe `deliverAs: "followUp"` queue and records structured `{ taskId, eventId }` insertion evidence. Replay after Pi has structurally recorded an event cannot create another logical receipt. `createInMemoryTaskRelay` remains exported solely as a deterministic conformance fixture.
26
26
 
27
+ ## retry-content stability (0.1.9)
28
+
29
+ New core-originated internal `RelayEnvelope` records persist an immutable ISO
30
+ `createdAt` alongside their outbox payload, before relay submission. Assignment,
31
+ intent and canonical-event envelopes all use that stored value on every Wolfpack
32
+ send, including after adapter/core/store restart. This is **not** a new
33
+ `agent_task_send` tool argument or a change to canonical task event authority.
34
+ The existing outbox serialization owns the metadata; no new database/schema or
35
+ relay spool is introduced. Wolfpack still hashes the full wire envelope, including
36
+ its timestamp: genuinely changed timestamp or payload must continue to conflict.
37
+
38
+ **Upgrade caveat:** pending envelopes written by older versions have no original
39
+ wire timestamp. The old adapter generated it on each attempt, so neither a new
40
+ wall-clock value nor an in-memory cache can reconstruct an already accepted
41
+ request safely. Missing/invalid metadata now fails before any relay request with
42
+ non-retryable `INVALID_RELAY_METADATA`; the core quarantines that pending envelope
43
+ unchanged using its existing delivery-blocked mechanism. Already accepted outbox
44
+ records and canonical task status are not rewritten. Inspect affected work and
45
+ its possibly-accepted outcome before choosing an explicit replacement; do not
46
+ blindly mint a new identity or silently backfill timestamps. Existing endpoint
47
+ rotation handling remains separate.
48
+
49
+ This correction alone does not activate Wolfpack's memory-owned transport. Sparse
50
+ cursor negotiation, live-process epoch reset and volatile forwarding outcomes
51
+ still require coordinated changes with Wolfpack #351; old forwarding acceptance
52
+ and cursor assumptions are not claimed fixed here.
53
+
54
+ ### real-relay retry regression
55
+
56
+ The ordinary tests use private fixtures. An optional cross-repository test also
57
+ runs the real core and adapter against a privately rooted real Wolfpack gateway
58
+ over loopback HTTP, loses a response after acceptance, reopens the endpoint store
59
+ and recreates the adapter, then verifies duplicate acceptance and genuine content
60
+ conflicts. Select a trusted, tracked-clean Wolfpack checkout and exact revision:
61
+
62
+ ```bash
63
+ PI_TASKS_WOLFPACK_SOURCE=/absolute/wolfpack-checkout \
64
+ PI_TASKS_WOLFPACK_REVISION=<full-commit-id> \
65
+ bun test tests/wolfpack-real-relay-retry.test.ts
66
+ ```
67
+
68
+ Without that explicit source selection the cross-repository test is skipped.
69
+ It does not contact an installed server, live broker or Tailnet peer, and is not
70
+ an end-to-end Pi/model execution, two-host, or volatile-profile test.
71
+
72
+ ## endpoint-view and delivery-checkpoint corrections (0.1.9)
73
+
74
+ Peer relay aliases are local routing names, not globally portable endpoint
75
+ identities. On receipt, the adapter projects only protocol-defined assignment and
76
+ canonical-event references into the recipient's namespace. The transport header
77
+ supplies the authenticated source peer route and local destination; sender-local
78
+ source IDs, peer-target IDs, and assignment/event reference agreement must match
79
+ before projection. Arbitrary application payloads, event IDs/order, outgoing wire
80
+ bytes, and historical task records are unchanged. Core ownership comparisons stay
81
+ strict. This relies on Wolfpack enforcing its trusted-peer ingress policy; the
82
+ adapter does not authenticate a peer by trusting a payload label.
83
+
84
+ Delivery ACKs acknowledge one envelope, **not** a cursor prefix. The core now
85
+ tracks observed pending cursor→envelope bindings and a conservative checkpoint in
86
+ endpoint-scoped `relay_state` metadata (existing SQLite schema 5, no migration).
87
+ A later automatic intent ACK cannot advance the checkpoint past an earlier
88
+ unacknowledged assignment. ACK intents are persisted before the request and only
89
+ those requested ACKs are retried on connect/receive after a response loss or
90
+ reopen. They use the durable envelope ID, not a receive-time RAM cache. Pending
91
+ bindings are removed after confirmation; the checkpoint/high-water mark does not
92
+ retain an individual completed-ACK history. Retired endpoint bindings are fenced.
93
+ Existing potentially unsafe checkpoints are **not** silently rewound or repaired.
94
+
95
+ The in-memory conformance fixture now models individual ACKs too. Optional
96
+ `tests/wolfpack-real-peer-core.test.ts` uses the same explicit trusted Wolfpack
97
+ source/revision variables as the retry test above. It exercises actual adapters,
98
+ cores, SQLite and two current v2 gateways over private loopback HTTP, including ACK
99
+ response loss/reopen, both-direction task messages and canonical/self fanout.
100
+ Broker/topology are synthetic: this is not live Tailnet/auth, compiled-worker,
101
+ physical-device or Pi/model execution proof.
102
+
103
+ These corrections do **not** activate the memory-owned profile. Negotiated profile/epoch
104
+ binding, actual sparse HTTP delivery cursors, live reset/rebind and terminal
105
+ forwarding outcomes remain outstanding. Endpoint-scoped checkpoints are not a
106
+ substitute for binding the future volatile adapter to its exact relay epoch.
107
+
27
108
  ## delegation workflow
28
109
 
29
110
  Configure Pi role models with `WOLFPACK_IMPLEMENTER_MODEL` and `WOLFPACK_REVIEWER_MODEL`; they default to `openai-codex/gpt-5.6-terra` and `openai-codex/gpt-5.6-sol`. Explicit user or project choices override those defaults.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Durable endpoint-owned Pi agent delegation through Wolfpack relay v2.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -31,7 +31,7 @@ export class InMemoryTaskRelay implements TaskRelay {
31
31
  private readonly endpoints = new Map<string, TaskEndpoint>();
32
32
  private readonly mailboxes = new Map<string, MailboxItem[]>();
33
33
  private readonly accepted = new Set<string>();
34
- private readonly acknowledged = new Map<string, number>();
34
+ private readonly acknowledged = new Set<string>();
35
35
  private nextDelivery = 0;
36
36
  private sendFailure = false;
37
37
 
@@ -72,11 +72,10 @@ export class InMemoryTaskRelay implements TaskRelay {
72
72
  this.assertEndpoint(input.endpoint);
73
73
  const mailbox = this.mailboxes.get(input.endpoint.id);
74
74
  if (!mailbox) throw new TaskProtocolError("UNREGISTERED_TARGET", "endpoint is not registered for this protocol");
75
- const acknowledged = this.acknowledged.get(input.endpoint.id) ?? 0;
76
75
  const cursor = Number(input.cursor);
77
76
  if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
78
- const start = Math.max(acknowledged, cursor);
79
- const pending = mailbox.filter((item) => Number(item.cursor) > start);
77
+ const start = cursor;
78
+ const pending = mailbox.filter((item) => Number(item.cursor) > start && !this.acknowledged.has(item.envelope.envelopeId));
80
79
  const deliveries = pending.slice(0, input.limit);
81
80
  return {
82
81
  deliveries,
@@ -89,7 +88,9 @@ export class InMemoryTaskRelay implements TaskRelay {
89
88
  this.assertEndpoint(input.endpoint);
90
89
  const cursor = Number(input.cursor);
91
90
  if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
92
- this.acknowledged.set(input.endpoint.id, Math.max(this.acknowledged.get(input.endpoint.id) ?? 0, cursor));
91
+ const item = this.mailboxes.get(input.endpoint.id)?.find(item => item.cursor === input.cursor);
92
+ if (!item) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not in this endpoint mailbox");
93
+ this.acknowledged.add(item.envelope.envelopeId);
93
94
  }
94
95
 
95
96
  failNextSend(): void {
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,
@@ -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) {
@@ -306,7 +328,7 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
306
328
  }
307
329
  }
308
330
  options.store.putIntent(intent.intentId, input.taskId, envelopeId);
309
- options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
331
+ options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent, now()));
310
332
  return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
311
333
  }
312
334
 
@@ -346,8 +368,8 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
346
368
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
347
369
  const sequence = String(task.events.length + 1);
348
370
  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);
371
+ const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
372
+ const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
351
373
  const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
352
374
  if (operation !== undefined) {
353
375
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
@@ -378,7 +400,7 @@ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined,
378
400
  options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
379
401
  } catch (error) {
380
402
  if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
381
- if (error instanceof TaskProtocolError && error.code === TARGET_NOT_REGISTERED_CODE && !error.retryable) {
403
+ if (error instanceof TaskProtocolError && (error.code === TARGET_NOT_REGISTERED_CODE || error.code === INVALID_RELAY_METADATA) && !error.retryable) {
382
404
  options.store.transaction(() => {
383
405
  options.store.quarantineOutbox(record.envelope.envelopeId, {
384
406
  errorCode: error.code,
@@ -413,8 +435,8 @@ function event(task: Omit<TaskRecord, "events">, eventId: string, type: string,
413
435
  return { eventId, taskId: task.taskId, type, sequence, source, target, occurredAt, payload };
414
436
  }
415
437
 
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) };
438
+ function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown, createdAt: number): RelayEnvelope {
439
+ return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload), createdAt: new Date(createdAt).toISOString() };
418
440
  }
419
441
 
420
442
  function parsePayload(envelope: RelayEnvelope): unknown {
@@ -1,5 +1,6 @@
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";
@@ -54,6 +55,9 @@ export interface RelayEnvelope {
54
55
  readonly target: TaskEndpoint;
55
56
  readonly taskId: string;
56
57
  readonly kind: TaskEnvelopeKind;
58
+ /** Immutable transport creation time, persisted with new outbox envelopes.
59
+ * Received/legacy records may omit it; Wolfpack sends require it. */
60
+ readonly createdAt?: string;
57
61
  /** Opaque to relays. The endpoint protocol defines its JSON representation. */
58
62
  readonly payload: string;
59
63
  }
@@ -82,6 +86,8 @@ export interface RelayInboxPage {
82
86
  export interface RelayDeliveryAck {
83
87
  readonly endpoint: TaskEndpoint;
84
88
  readonly cursor: string;
89
+ /** Durable cursor-to-envelope binding, for identical ACK retry after reopen. */
90
+ readonly envelopeId?: string;
85
91
  }
86
92
 
87
93
  export interface TaskRelay {
@@ -90,6 +96,7 @@ export interface TaskRelay {
90
96
  resolve(input: RelayTargetReference, signal?: AbortSignal): Promise<TaskEndpoint>;
91
97
  send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance>;
92
98
  receive(input: RelayReceiveRequest, signal?: AbortSignal): Promise<RelayInboxPage>;
99
+ /** Acknowledge this delivery only, never an implicit cursor prefix. */
93
100
  acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void>;
94
101
  }
95
102
 
@@ -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 });
package/src/task-store.ts CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
 
5
5
  import { openSqliteDatabase } from "./sqlite-database";
6
6
  import type { SqliteDatabase } from "./sqlite-database";
7
- import { TERMINAL_INTENT_OPERATION } from "./task-protocol";
7
+ import { TERMINAL_INTENT_OPERATION, TaskProtocolError } from "./task-protocol";
8
8
  import type { RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
9
9
 
10
10
  const SCHEMA_VERSION = 5;
@@ -57,6 +57,11 @@ export interface TaskStore {
57
57
  persistInbox(envelope: RelayEnvelope, cursor: string): boolean;
58
58
  getReceiveCursor(): string;
59
59
  setReceiveCursor(cursor: string): void;
60
+ trackRelayDeliveries(endpoint: TaskEndpoint, deliveries: readonly { readonly cursor: string; readonly envelopeId: string }[]): void;
61
+ pendingRelayEnvelopeId(endpoint: TaskEndpoint, cursor: string): string | undefined;
62
+ requestRelayAcknowledgement(endpoint: TaskEndpoint, cursor: string): void;
63
+ requestedRelayAcknowledgements(endpoint: TaskEndpoint): readonly string[];
64
+ acknowledgeRelayCursor(endpoint: TaskEndpoint, cursor: string): void;
60
65
  putIntent(intentId: string, taskId: string, envelopeId: string): void;
61
66
  putInsertionReceipt(taskId: string, eventId: string): boolean;
62
67
  reserveTaskOperation(input: TaskOperationRecord): { readonly created: boolean; readonly record: TaskOperationRecord };
@@ -155,6 +160,39 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
155
160
  setReceiveCursor(cursor) {
156
161
  database.query("INSERT INTO relay_state (name, value) VALUES ('receive_cursor', ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(cursor);
157
162
  },
163
+ trackRelayDeliveries(endpoint, deliveries) {
164
+ const state = deliveryCheckpoint(endpoint);
165
+ const priorHighWater = BigInt(state.highWater);
166
+ for (const delivery of deliveries) {
167
+ const prior = state.pending[delivery.cursor];
168
+ if (prior !== undefined && prior !== delivery.envelopeId) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor changed envelope identity");
169
+ // A previously observed but no longer pending cursor was ACKed. In
170
+ // particular, legacy relays may still return ACKed rows on reread.
171
+ if (BigInt(delivery.cursor) > priorHighWater) state.pending[delivery.cursor] = delivery.envelopeId;
172
+ if (BigInt(delivery.cursor) > BigInt(state.highWater)) state.highWater = delivery.cursor;
173
+ }
174
+ writeDeliveryCheckpoint(endpoint, state);
175
+ },
176
+ pendingRelayEnvelopeId(endpoint, cursor) { return deliveryCheckpoint(endpoint).pending[cursor]; },
177
+ requestRelayAcknowledgement(endpoint, cursor) {
178
+ const state = deliveryCheckpoint(endpoint);
179
+ if (state.pending[cursor] === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
180
+ (state.requested ??= {})[cursor] = true;
181
+ writeDeliveryCheckpoint(endpoint, state);
182
+ },
183
+ requestedRelayAcknowledgements(endpoint) { return Object.keys(deliveryCheckpoint(endpoint).requested ?? {}); },
184
+ acknowledgeRelayCursor(endpoint, cursor) {
185
+ const state = deliveryCheckpoint(endpoint);
186
+ // Concurrent confirmations of the same requested ACK are harmless.
187
+ if (state.pending[cursor] === undefined) return;
188
+ delete state.pending[cursor];
189
+ if (state.requested) delete state.requested[cursor];
190
+ const pending = Object.keys(state.pending);
191
+ if (pending.length === 0) state.frontier = state.highWater;
192
+ else if (BigInt(cursor) > BigInt(state.frontier) && pending.every(value => BigInt(value) > BigInt(cursor))) state.frontier = cursor;
193
+ writeDeliveryCheckpoint(endpoint, state);
194
+ store.setReceiveCursor(state.frontier);
195
+ },
158
196
  putIntent(intentId, taskId, envelopeId) {
159
197
  database.query("INSERT INTO intents (intent_id, task_id, envelope_id) VALUES (?, ?, ?) ON CONFLICT(intent_id) DO NOTHING").run(intentId, taskId, envelopeId);
160
198
  },
@@ -185,9 +223,30 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
185
223
  },
186
224
  close() { database.close(); },
187
225
  };
226
+ function checkpointKey(endpoint: TaskEndpoint): string {
227
+ const bound = store.getEndpointBinding();
228
+ if (bound && (bound.relay !== endpoint.relay || bound.id !== endpoint.id)) throw new TaskProtocolError("RELAY_RESET", "delivery checkpoint belongs to a retired endpoint", { retryable: false });
229
+ return `delivery_checkpoint:${JSON.stringify([endpoint.relay, endpoint.id])}`;
230
+ }
231
+ function deliveryCheckpoint(endpoint: TaskEndpoint): DeliveryCheckpoint {
232
+ const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(checkpointKey(endpoint)) as { readonly value: string } | null;
233
+ if (row) return JSON.parse(row.value) as DeliveryCheckpoint;
234
+ const frontier = store.getReceiveCursor();
235
+ return { frontier, highWater: frontier, pending: {} };
236
+ }
237
+ function writeDeliveryCheckpoint(endpoint: TaskEndpoint, state: DeliveryCheckpoint): void {
238
+ database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(checkpointKey(endpoint), JSON.stringify(state));
239
+ }
188
240
  return store;
189
241
  }
190
242
 
243
+ interface DeliveryCheckpoint {
244
+ frontier: string;
245
+ highWater: string;
246
+ pending: Record<string, string>;
247
+ requested?: Record<string, true>;
248
+ }
249
+
191
250
  interface TaskRow {
192
251
  readonly task_id: string;
193
252
  readonly protocol_version: string;
@@ -0,0 +1,46 @@
1
+ import { TaskProtocolError } from "./task-protocol";
2
+ import type { RelayEnvelope, TaskEndpoint } from "./task-protocol";
3
+
4
+ export const WOLFPACK_TASK_RELAY_ID = "wolfpack-pi-tasks-v2";
5
+ const PEER = new RegExp(`^${WOLFPACK_TASK_RELAY_ID}:peer:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, "i");
6
+
7
+ /**
8
+ * Project only protocol-defined references from the sender's local namespace
9
+ * into the recipient's namespace. The authenticated transport header supplies
10
+ * the source peer route and destination identity; payload labels cannot do so.
11
+ * Outgoing wire bytes, opaque application payloads and historical rows stay put.
12
+ */
13
+ export function wolfpackEndpointView(source: TaskEndpoint, target: TaskEndpoint, kind: RelayEnvelope["kind"], payload: unknown): unknown {
14
+ if (source.relay === WOLFPACK_TASK_RELAY_ID && target.relay === WOLFPACK_TASK_RELAY_ID) return payload; // Includes origin/self canonical fanout.
15
+ if (!PEER.test(source.relay) || target.relay !== WOLFPACK_TASK_RELAY_ID) invalid();
16
+ if (kind === "intent") return payload; // Intents carry no protocol endpoint references.
17
+ if (!record(payload)) invalid();
18
+ const originalSource = { relay: WOLFPACK_TASK_RELAY_ID, id: source.id };
19
+ const validateSource = (value: unknown): void => {
20
+ if (!endpoint(value) || !same(value, originalSource)) invalid();
21
+ };
22
+ const validateTarget = (value: unknown): TaskEndpoint => {
23
+ if (!endpoint(value) || !PEER.test(value.relay) || value.id !== target.id) invalid();
24
+ return value;
25
+ };
26
+ if (kind === "canonical_event") {
27
+ validateSource(payload.source); validateTarget(payload.target);
28
+ return { ...payload, source, target };
29
+ }
30
+ if (!record(payload.task) || !record(payload.event)) invalid();
31
+ validateSource(payload.task.origin); validateSource(payload.event.source);
32
+ const originalTarget = validateTarget(payload.task.target);
33
+ if (!endpoint(payload.event.target) || !same(payload.event.target, originalTarget)) invalid();
34
+ return {
35
+ ...payload,
36
+ task: { ...payload.task, origin: source, target },
37
+ event: { ...payload.event, source, target },
38
+ };
39
+ }
40
+
41
+ function invalid(): never {
42
+ throw new TaskProtocolError("INVALID_ENDPOINT_REFERENCE", "peer task references do not match the transport's endpoint roles", { retryable: false });
43
+ }
44
+ function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
45
+ function endpoint(value: unknown): value is TaskEndpoint { return record(value) && typeof value.relay === "string" && typeof value.id === "string"; }
46
+ function same(a: TaskEndpoint, b: TaskEndpoint): boolean { return a.relay === b.relay && a.id === b.id; }
@@ -3,8 +3,9 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  import { createTaskCore } from "./task-core";
6
+ import { WOLFPACK_TASK_RELAY_ID, wolfpackEndpointView } from "./wolfpack-endpoint-view";
6
7
  import { createTaskStore } from "./task-store";
7
- import { TASK_PROTOCOL_VERSION, TaskProtocolError } from "./task-protocol";
8
+ import { INVALID_RELAY_METADATA, TASK_PROTOCOL_VERSION, TaskProtocolError } from "./task-protocol";
8
9
  import type {
9
10
  RelayAcceptance,
10
11
  RelayConnectInput,
@@ -20,7 +21,7 @@ import type {
20
21
  import type { TaskCoreOptions } from "./task-core";
21
22
  import type { TaskStore, TaskStoreOptions } from "./task-store";
22
23
 
23
- export const WOLFPACK_TASK_RELAY_ID = "wolfpack-pi-tasks-v2";
24
+ export { WOLFPACK_TASK_RELAY_ID } from "./wolfpack-endpoint-view";
24
25
  export const WOLFPACK_TASK_RELAY_PROTOCOL_VERSION = 2;
25
26
  export const WOLFPACK_TASK_RELAY_LEASE_MS = 60_000;
26
27
  const DEFAULT_WOLFPACK_PORT = 18_790;
@@ -139,10 +140,13 @@ export function createWolfpackTaskRelay(options: WolfpackTaskRelayOptions = {}):
139
140
  return response.endpoint;
140
141
  },
141
142
  async send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance> {
143
+ // Validate/capture before registration or any send. A fresh timestamp or
144
+ // process-local cache cannot safely retry a previously accepted envelope.
145
+ const envelope = toWolfpackEnvelope(input);
142
146
  await register(signal);
143
147
  const response = await request<WolfpackSendResponse>(requestFetch, baseUrl, requestTimeoutMs, "POST", "/api/task-relay/v2/send", {
144
148
  callerSession: requiredSession(callerSession),
145
- envelope: toWolfpackEnvelope(input),
149
+ envelope,
146
150
  }, signal);
147
151
  if (!nonEmpty(response.acceptanceId)) throw new TaskProtocolError("INVALID_ACCEPTANCE", "Wolfpack relay returned an invalid acceptance");
148
152
  return { envelopeId: input.envelopeId };
@@ -165,9 +169,12 @@ export function createWolfpackTaskRelay(options: WolfpackTaskRelayOptions = {}):
165
169
  return { deliveries, nextCursor: response.nextCursor, hasMore: response.hasMore || response.envelopes.length > envelopes.length };
166
170
  },
167
171
  async acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void> {
168
- await register(signal);
169
- const envelopeId = envelopeIds.get(input.cursor);
170
- if (!envelopeId) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery cursor is not available for acknowledgement");
172
+ const registered = await register(signal);
173
+ if (!sameEndpoint(input.endpoint, registered)) throw new TaskProtocolError("INVALID_CONNECTION", "delivery acknowledgement belongs to a different endpoint");
174
+ const cachedId = envelopeIds.get(input.cursor);
175
+ if (cachedId !== undefined && input.envelopeId !== undefined && cachedId !== input.envelopeId) throw new TaskProtocolError("INVALID_CURSOR", "delivery acknowledgement conflicts with its cursor binding");
176
+ const envelopeId = input.envelopeId ?? cachedId;
177
+ if (!nonEmpty(envelopeId)) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery cursor is not available for acknowledgement");
171
178
  await request<WolfpackRelayResponse>(requestFetch, baseUrl, requestTimeoutMs, "POST", "/api/task-relay/v2/delivery-ack", { callerSession: requiredSession(callerSession), envelopeId }, signal);
172
179
  envelopeIds.delete(input.cursor);
173
180
  },
@@ -214,6 +221,12 @@ function bindRegisteredEndpoint(store: TaskStore, endpoint: TaskEndpoint, now: n
214
221
  }
215
222
 
216
223
  function toWolfpackEnvelope(envelope: RelayEnvelope): WolfpackRelayEnvelope {
224
+ const createdAt = envelope.createdAt;
225
+ if (!transportTimestamp(createdAt)) {
226
+ throw new TaskProtocolError(INVALID_RELAY_METADATA, "relay envelope requires an immutable persisted creation timestamp; legacy wire metadata cannot be reconstructed safely", {
227
+ retryable: false, details: { envelopeId: envelope.envelopeId, reason: createdAt === undefined ? "missing" : "invalid" },
228
+ });
229
+ }
217
230
  let payload: unknown;
218
231
  try {
219
232
  payload = JSON.parse(envelope.payload) as unknown;
@@ -226,7 +239,7 @@ function toWolfpackEnvelope(envelope: RelayEnvelope): WolfpackRelayEnvelope {
226
239
  source: envelope.source,
227
240
  target: envelope.target,
228
241
  payload: { taskId: envelope.taskId, kind: envelope.kind, payload },
229
- createdAt: new Date().toISOString(),
242
+ createdAt,
230
243
  };
231
244
  }
232
245
 
@@ -236,7 +249,7 @@ function fromWolfpackEnvelope(envelope: WolfpackRelayEnvelope): RelayEnvelope {
236
249
  }
237
250
  const taskId = taskIdFromPayload(envelope.payload);
238
251
  const kind = envelopeKindFromPayload(envelope.payload);
239
- const payload = JSON.stringify(relayPayload(envelope.payload));
252
+ const payload = JSON.stringify(wolfpackEndpointView(envelope.source, envelope.target, kind, relayPayload(envelope.payload)));
240
253
  if (payload === undefined) throw new TaskProtocolError("INVALID_PAYLOAD", "Wolfpack relay envelope payload is not JSON-serializable");
241
254
  return { envelopeId: envelope.envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source: envelope.source, target: envelope.target, taskId, kind, payload };
242
255
  }
@@ -313,6 +326,12 @@ function boundedRequestTimeout(value: number): number {
313
326
  return Number.isInteger(value) && value >= 1 && value <= MAX_REQUEST_TIMEOUT_MS ? value : DEFAULT_REQUEST_TIMEOUT_MS;
314
327
  }
315
328
 
329
+ function transportTimestamp(value: unknown): value is string {
330
+ if (typeof value !== "string") return false;
331
+ const parsed = Date.parse(value);
332
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
333
+ }
334
+
316
335
  function futureTimestamp(value: unknown): value is string {
317
336
  return typeof value === "string" && Number.isFinite(Date.parse(value)) && Date.parse(value) > Date.now();
318
337
  }