@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 +81 -0
- package/package.json +1 -1
- package/src/in-memory-task-relay.ts +6 -5
- package/src/task-core.ts +36 -14
- package/src/task-protocol.ts +7 -0
- package/src/task-relay-conformance.ts +11 -1
- package/src/task-store.ts +60 -1
- package/src/wolfpack-endpoint-view.ts +46 -0
- package/src/wolfpack-task-relay.ts +27 -8
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
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 {
|
package/src/task-protocol.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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
|
}
|