@sgtbeatdown/pi-tasks 0.1.7 → 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.
@@ -33,13 +114,13 @@ IMPLEMENTER_MODEL="${WOLFPACK_IMPLEMENTER_MODEL:-openai-codex/gpt-5.6-terra}"
33
114
  REVIEWER_MODEL="${WOLFPACK_REVIEWER_MODEL:-openai-codex/gpt-5.6-sol}"
34
115
  ```
35
116
 
36
- 1. create or select a role session. For a disposable worker, omit an initial assignment prompt and pass the resolved role model: `wolfpack agent spawn <project> --name <task-role> --model "$IMPLEMENTER_MODEL" --json` (or `"$REVIEWER_MODEL"` for review). Put all worker instructions in `agent_task_send.task` so the new Pi process can become idle before assignment.
37
- 2. verify structured session readiness, wait for extension registration, and read `taskEndpoint` from `wolfpack session status <session> --json`.
38
- 3. call `agent_task_send` with that endpoint and the complete instructions. Keep working; use `agent_task_status` or `agent_task_inbox` for structured evidence, and call `agent_task_wait` only when explicitly asked to block.
117
+ 1. create or select a role session. For a new endpoint worker, use its explicit root and resolved role model without a startup assignment: `wolfpack agent spawn --project-dir /absolute/worktree --name <task-role> --model "$IMPLEMENTER_MODEL" --task-worker --readiness-timeout-ms 30000 --json` (or `"$REVIEWER_MODEL"` for review). Put all worker instructions in `agent_task_send.task`. This Pi-only mode rejects prompts/plans and `--notify-parent`.
118
+ 2. read the ready `taskEndpoint` from creation success, or verify an existing role's structured liveness, root, harness, and endpoint through `wolfpack session status <stable-session-id> --json`. `TASK_WORKER_PREFLIGHT_FAILED` precedes creation; `TASK_WORKER_NOT_READY` retains `createdSession` and `cleanup`. Inspect an `unconfirmed` cleanup by exact stable ID before retrying. Registration is not model/task execution evidence; report unsupported readiness instead of silently falling back.
119
+ 3. call `agent_task_send` with that endpoint and the complete instructions. Continue useful independent coordinator work; when none remains, yield the current turn and let structured task follow-up wake the parent. Do not poll `agent_task_status` or `agent_task_inbox` merely to wait; reserve them for concrete progress evidence or recovery, and call `agent_task_wait` only when explicitly asked to block.
39
120
  4. use `agent_task_message` for durable questions, answers, and information. The receiver calls `agent_task_done` as its final action; no completion prose follows.
40
121
  5. independently verify the result, call `agent_task_ack({ taskId })` once for that terminal task, then explicitly retain or close only the role sessions the parent spawned.
41
122
 
42
- Coordinator-capable agents may delegate further when justified, and the spawning coordinator owns each child's lifecycle. After terminal completion and acknowledgment, deliberately retain the child or run `wolfpack kill <stable-session-id> --json`, then verify that exact ID is absent from `wolfpack list --json`. Never use `wolfpack session send`, `/exit`, or `/quit` for cleanup; terminal input is not Wolfpack teardown.
123
+ Coordinator-capable agents may delegate further when justified, and the spawning coordinator owns each child's lifecycle. Endpoint assignments require terminal completion and one `agent_task_ack`; full-startup children have no task ID, so use their explicit completion/block notification and parent verification instead. Then deliberately retain the child or run `wolfpack kill <stable-session-id> --json`, and verify that exact ID is absent from `wolfpack list --json`. Never use `wolfpack session send`, `/exit`, or `/quit` for cleanup; terminal input is not Wolfpack teardown.
43
124
 
44
125
  ## worker-only execution gate
45
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.7",
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,8 +31,9 @@ Pass `--model "$IMPLEMENTER_MODEL"` when spawning the editing implementer and `-
31
31
 
32
32
  - load this package's default extension in every participating Pi process and set `WOLFPACK_SESSION_NAME`. Set `WOLFPACK_PORT` only when the local Wolfpack control port differs from `18790`.
33
33
  - address `agent_task_send` targets only as `to: { relay, id }`. For the default Wolfpack adapter, the relay is `wolfpack-pi-tasks-v2` and the ID is opaque.
34
- - create a disposable worker without an initial assignment prompt: `wolfpack agent spawn <project> --name <task-role> --model "$IMPLEMENTER_MODEL" --json` (or `"$REVIEWER_MODEL"` for review). Do not start a disposable worker with a blocking “wait for assignments” prompt. Put the complete instructions in `agent_task_send.task`.
35
- - after the target extension registers, run structured session control (`wolfpack session status <session> --json`) and read its `taskEndpoint`. Do not derive endpoint IDs from session names, broker IDs, terminal labels, output, or prose.
34
+ - create an endpoint worker with opt-in readiness and no initial assignment prompt: `wolfpack agent spawn --project-dir /absolute/worktree --name <task-role> --model "$IMPLEMENTER_MODEL" --task-worker --readiness-timeout-ms 30000 --json` (or `"$REVIEWER_MODEL"` for review). Use the actual explicit project root. This Pi-only mode rejects prompts/plans and `--notify-parent`; do not start a blocking “wait for assignments” prompt. Put the complete instructions in `agent_task_send.task`.
35
+ - read the returned `taskEndpoint` after exact live session/root and relay registration readiness succeeds. For a selected existing session, inspect structured status by its stable ID. Do not derive endpoint IDs from session names, broker IDs, terminal labels, output, or prose. Registration proves neither provider/model readiness nor task execution.
36
+ - inspect typed creation errors before retrying: `TASK_WORKER_PREFLIGHT_FAILED` precedes creation; `TASK_WORKER_NOT_READY` retains `createdSession` and `cleanup`. An `unconfirmed` cleanup requires exact-ID inspection, not an assumption that the session is gone. Do not silently fall back to ordinary spawn if the installed Wolfpack lacks readiness support; report the capability gap.
36
37
  - pass the returned endpoint without translation:
37
38
 
38
39
  ```json
@@ -49,7 +50,7 @@ The default `agent_task_send` schema is exactly `to`, `task`, and optional `time
49
50
 
50
51
  For a multi-step project phase, retain one persistent implementer and one persistent read-only reviewer. Give the implementer the whole approved phase, including required commit ordering and focused verification, rather than opening one task per issue or checkpoint. Reuse a healthy role session for corrections and follow-up review; a completed task finishes one assignment, not the underlying session. Do not rotate a healthy role session for routine corrections. Rotate only for phase completion, material context degradation, harness failure, or required specialist independence. Keep at most one active assignment per role unless the user explicitly approves parallel work.
51
52
 
52
- A coordinator-capable agent may delegate further when the quality or throughput gain justifies it. The spawning coordinator owns each child's complete lifecycle: record the stable session ID, acknowledge its terminal task once, then deliberately retain the child or close it with `wolfpack kill <stable-session-id> --json` and verify that exact ID is absent from `wolfpack list --json`.
53
+ A coordinator-capable agent may delegate further when the quality or throughput gain justifies it. For an endpoint assignment, the spawning coordinator owns task lifecycle: record the stable session ID, acknowledge its terminal task once, then deliberately retain the child or close it with `wolfpack kill <stable-session-id> --json` and verify that exact ID is absent from `wolfpack list --json`. Full-startup children have no endpoint task ID; use their explicit completion/block notification plus parent verification before the same retain-or-exact-ID-kill decision.
53
54
 
54
55
  `PI_TASK_WORKER=1` sessions are leaf roles. Workers cannot call `agent_task_send`; workers cannot call `agent_task_cancel`; workers cannot call `agent_task_ack`. These coordinator-tool attempts are blocked with the stable reason `PI_TASK_WORKER_COORDINATION_FORBIDDEN`, not the pre-assignment reason. They may use `agent_task_message` only for the eligible incorporated assignment named by its input `taskId`. Generic role-orchestration guidance applies only to non-worker coordinators. Task workers must not create, rotate, or close Wolfpack sessions through shell or session-control tools.
55
56
 
@@ -70,9 +71,9 @@ Assignment completion ends the assigned task, not the reusable role session. Whe
70
71
 
71
72
  ## v2 workflow
72
73
 
73
- 1. create or select the role session, verify its structured project and terminal readiness, then obtain its registered `taskEndpoint`.
74
+ 1. create a role session through the readiness path above, or select an existing role by stable ID and verify its structured liveness, canonical project, Pi harness, and registered `taskEndpoint`. Never use terminal output as readiness evidence.
74
75
  2. call `agent_task_send` with the opaque endpoint and concise instructions in `task`. The tool returns after relay acceptance only, not Pi insertion or model execution. If submission reports a retryable delivery error after local persistence, use the structured task ID from the error and inspect it rather than creating an unrelated replacement.
75
- 3. keep working. Use `agent_task_status` or `agent_task_inbox` only at meaningful checkpoints or when progress evidence is needed; do not turn status checks into lifecycle chatter. Call `agent_task_wait` only when the user explicitly asks to block.
76
+ 3. after `agent_task_send`, continue useful independent coordinator work. When none remains, yield the current turn and let structured task follow-up wake the parent; do not poll `agent_task_status` or `agent_task_inbox` merely to wait. Reserve those tools for concrete progress evidence or recovery, and call `agent_task_wait` only when the user explicitly asks to block.
76
77
  4. use `agent_task_message` for durable `question`, `answer`, or `information` flow. Do not use rendered text, terminal output, or logs as lifecycle evidence.
77
78
  5. assignees call `agent_task_done` as their final action with the assigned task ID, terminal status, concise summary, and optional structured result, error, and artifact declarations. Report source modifications in `result.changedFiles`; artifacts are receiver-project-relative regular files for a parent to inspect, not changed-file lists: `{ "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }`. No prose completion afterward.
78
79
  6. the parent independently verifies files, diff, tests, and artifacts. Then call `agent_task_ack({ taskId })` for that one terminal task and explicitly retain or close the spawned role session.
@@ -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
  }