@sgtbeatdown/wolfpack-tasks 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,111 @@
1
+ // The single error-code registry. It imports nothing from task-protocol so that module can re-export from here.
2
+
3
+ export type TaskErrorClass = "transport" | "capacity" | "protocol" | "state" | "auth" | "delivery";
4
+
5
+ /** Class for a code missing from the registry, e.g. a newer server's. */
6
+ export const UNKNOWN_TASK_ERROR_CLASS = "error";
7
+
8
+ interface TaskErrorSpec {
9
+ readonly class: TaskErrorClass;
10
+ /** Default for throwers without better information; an explicit per-throw `retryable` always wins. */
11
+ readonly retryable: boolean;
12
+ readonly message: string;
13
+ /** A transport rejection with this code is retryable whatever the server flag says. */
14
+ readonly forceRetry?: true;
15
+ /** The volatile binding behind this response is gone; the session retires it. */
16
+ readonly resetsBinding?: true;
17
+ /** Relay task state was lost; the extension rebinds automatically. */
18
+ readonly relayLoss?: true;
19
+ /** A non-retryable send outcome that quarantines the envelope as blocked delivery. */
20
+ readonly blocksDelivery?: true;
21
+ }
22
+
23
+ export const TASK_ERRORS: Readonly<Record<string, TaskErrorSpec>> = {
24
+ // transport
25
+ RELAY_UNAVAILABLE: { class: "transport", retryable: true, forceRetry: true, message: "relay is unavailable" },
26
+ PEER_UNREACHABLE: { class: "transport", retryable: true, forceRetry: true, message: "peer relay is unreachable" },
27
+ RELAY_TIMEOUT: { class: "transport", retryable: true, message: "relay request timed out" },
28
+ RELAY_BUSY: { class: "transport", retryable: true, message: "relay handshake is already in progress" },
29
+ ABORTED: { class: "transport", retryable: true, message: "request was aborted" },
30
+ INVALID_RESPONSE: { class: "transport", retryable: true, message: "relay response is malformed" },
31
+ // capacity
32
+ RELAY_CAPACITY: { class: "capacity", retryable: true, forceRetry: true, message: "relay is at capacity" },
33
+ TASK_CAPACITY: { class: "capacity", retryable: false, message: "active task state is full" },
34
+ PAYLOAD_TOO_LARGE: { class: "capacity", retryable: false, message: "task payload is too large" },
35
+ TASK_DELIVERY_DEFERRED: { class: "capacity", retryable: true, message: "task delivery deferred by the local work budget or retry cooldown" },
36
+ // auth and policy
37
+ RELAY_AUTH_REQUIRED: { class: "auth", retryable: false, message: "Wolfpack authentication required" },
38
+ PEER_POLICY_REQUIRED: { class: "auth", retryable: false, message: "peer relay policy does not allow this request" },
39
+ UNAUTHORIZED_INTENT: { class: "auth", retryable: false, message: "intent is not authorized for this task" },
40
+ PI_TASK_WORKER_ASSIGNMENT_REQUIRED: { class: "auth", retryable: false, message: "worker tools require an active assignment" },
41
+ PI_TASK_WORKER_COORDINATION_FORBIDDEN: { class: "auth", retryable: false, message: "workers cannot coordinate other tasks" },
42
+ // relay and task state
43
+ RELAY_RESET: { class: "state", retryable: false, resetsBinding: true, relayLoss: true, message: "relay lifetime was reset" },
44
+ RELAY_REBIND_REQUIRED: { class: "state", retryable: false, relayLoss: true, message: "relay binding must be replaced" },
45
+ RELAY_STATE_LOST: { class: "state", retryable: false, message: "relay task state was lost" },
46
+ REGISTRATION_EXPIRED: { class: "state", retryable: false, resetsBinding: true, message: "relay registration expired" },
47
+ SOURCE_MISMATCH: { class: "state", retryable: false, resetsBinding: true, message: "relay source does not match the binding" },
48
+ CALLER_DEAD: { class: "state", retryable: false, resetsBinding: true, message: "calling session is gone" },
49
+ CALLER_NOT_FOUND: { class: "state", retryable: false, resetsBinding: true, message: "calling session is not found" },
50
+ RELAY_CLOSED: { class: "state", retryable: false, message: "task lifetime is closed" },
51
+ UNKNOWN_TASK: { class: "state", retryable: false, message: "unknown task" },
52
+ TASK_NOT_FOUND: { class: "state", retryable: false, message: "task is not active in this endpoint lifetime" },
53
+ TASK_NOT_TERMINAL: { class: "state", retryable: false, message: "only a terminal task may be acknowledged" },
54
+ TERMINAL_INTENT_CONFLICT: { class: "state", retryable: false, message: "a different terminal intent was already submitted" },
55
+ NOT_ORIGIN: { class: "state", retryable: false, message: "only the origin may perform this operation" },
56
+ NOT_PARTICIPANT: { class: "state", retryable: false, message: "endpoint is not a task participant" },
57
+ REASSIGNMENT_REFUSED: { class: "state", retryable: false, message: "task cannot be reassigned" },
58
+ TASK_ERROR: { class: "state", retryable: true, message: "task operation failed" },
59
+ // delivery
60
+ TARGET_NOT_REGISTERED: { class: "delivery", retryable: false, blocksDelivery: true, message: "target is not registered" },
61
+ INVALID_RELAY_METADATA: { class: "delivery", retryable: false, blocksDelivery: true, message: "relay metadata is invalid" },
62
+ DELIVERY_UNCONFIRMED: { class: "delivery", retryable: false, blocksDelivery: true, message: "peer delivery outcome is unknown" },
63
+ ENVELOPE_EXPIRED: { class: "delivery", retryable: false, blocksDelivery: true, message: "envelope expired before delivery" },
64
+ ENVELOPE_CONFLICT: { class: "delivery", retryable: false, blocksDelivery: true, message: "envelope id was reused with different content" },
65
+ CROSS_RELAY_ENDPOINT: { class: "delivery", retryable: false, blocksDelivery: true, message: "endpoint belongs to another relay" },
66
+ UNREGISTERED_TARGET: { class: "delivery", retryable: true, message: "target is not registered for this protocol" },
67
+ UNREGISTERED_SOURCE: { class: "delivery", retryable: true, message: "source is not registered for this protocol" },
68
+ UNKNOWN_RELAY: { class: "delivery", retryable: true, message: "target belongs to another relay" },
69
+ // protocol
70
+ RELAY_PROFILE_REQUIRED: { class: "protocol", retryable: false, message: "compatible memory-owned relay required" },
71
+ INCOMPATIBLE_PROTOCOL: { class: "protocol", retryable: false, message: "incompatible task protocol" },
72
+ INVALID_REQUEST: { class: "protocol", retryable: false, message: "relay rejected the request as invalid" },
73
+ INVALID_CONNECTION: { class: "protocol", retryable: true, message: "relay connected a different endpoint" },
74
+ INVALID_ACCEPTANCE: { class: "protocol", retryable: true, message: "relay acceptance is malformed" },
75
+ INVALID_INBOX: { class: "protocol", retryable: true, message: "relay inbox page is malformed" },
76
+ INVALID_DELIVERY: { class: "protocol", retryable: true, message: "relay returned an invalid or unordered delivery" },
77
+ INVALID_CURSOR: { class: "protocol", retryable: true, message: "relay cursor is invalid" },
78
+ INVALID_ENVELOPE: { class: "protocol", retryable: true, message: "relay envelope is malformed" },
79
+ INVALID_PAYLOAD: { class: "protocol", retryable: true, message: "relay envelope payload is invalid" },
80
+ INVALID_TARGET: { class: "protocol", retryable: true, message: "target is invalid" },
81
+ INVALID_ENDPOINT: { class: "protocol", retryable: true, message: "endpoint is invalid" },
82
+ INVALID_ENDPOINT_REFERENCE: { class: "protocol", retryable: true, message: "endpoint reference is invalid" },
83
+ INVALID_TASK: { class: "protocol", retryable: true, message: "task and a positive timeout are required" },
84
+ INVALID_ASSIGNMENT: { class: "protocol", retryable: true, message: "assignment is invalid" },
85
+ INVALID_INTENT: { class: "protocol", retryable: true, message: "task intent is invalid" },
86
+ INVALID_EVENT: { class: "protocol", retryable: true, message: "task event is invalid" },
87
+ INVALID_INBOX_EVENT: { class: "protocol", retryable: true, message: "relay envelope does not contain a valid model event" },
88
+ UNKNOWN_EVENT: { class: "protocol", retryable: true, message: "unknown task event type" },
89
+ };
90
+
91
+ function codes(flag: (spec: TaskErrorSpec) => boolean | undefined): ReadonlySet<string> {
92
+ return new Set(Object.entries(TASK_ERRORS).flatMap(([code, spec]) => flag(spec) ? [code] : []));
93
+ }
94
+
95
+ export const RETRY_CODES = codes(spec => spec.forceRetry);
96
+ export const RESET_CODES = codes(spec => spec.resetsBinding);
97
+ export const RELAY_LOSS_CODES = codes(spec => spec.relayLoss);
98
+ const BLOCKED_OUTBOX_DELIVERY_CODES = codes(spec => spec.blocksDelivery);
99
+
100
+ export function isBlockedOutboxDeliveryCode(code: string): boolean {
101
+ return BLOCKED_OUTBOX_DELIVERY_CODES.has(code);
102
+ }
103
+
104
+ export function taskErrorClass(code: string): TaskErrorClass | typeof UNKNOWN_TASK_ERROR_CLASS {
105
+ return TASK_ERRORS[code]?.class ?? UNKNOWN_TASK_ERROR_CLASS;
106
+ }
107
+
108
+ /** The registry message, or the code itself for an unregistered code. */
109
+ export function taskErrorMessage(code: string): string {
110
+ return TASK_ERRORS[code]?.message ?? code;
111
+ }
@@ -0,0 +1,292 @@
1
+ import type { TaskCore } from "./task-core";
2
+ import { createTaskWorkScope } from "./task-work-scope";
3
+ import type { TaskShipment, TaskWorkScope } from "./task-work-scope";
4
+ import { TASK_EVENT_TYPES, TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
5
+ import type { RelayDelivery, TaskEvent } from "./task-protocol";
6
+
7
+ const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
8
+ const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
9
+ const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
10
+ const TASK_RECORD_CUSTOM_TYPE = "pi-tasks-event-record";
11
+
12
+ interface InboxContext {
13
+ readonly isIdle: () => boolean;
14
+ readonly hasPendingMessages: () => boolean;
15
+ readonly sessionManager: { readonly getEntries: () => readonly unknown[] };
16
+ }
17
+
18
+ interface InboxPi {
19
+ sendMessage(
20
+ message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails },
21
+ options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
22
+ ): void;
23
+ appendEntry(customType: string, data: { readonly cursor: string } | TaskEventDetails): void;
24
+ }
25
+
26
+ export interface TaskEventDetails {
27
+ readonly taskId: string;
28
+ readonly eventId: string;
29
+ /** Historical evidence only; never used to rebuild active task state. */
30
+ readonly event?: TaskEvent;
31
+ }
32
+
33
+ type DeliveryEvidencePayload =
34
+ | { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
35
+ | { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
36
+
37
+ /** Relay items carry delivery receipts; origin-local items never emit receipts, insertion records or ACKs. */
38
+ interface RelayReceipts {
39
+ readonly signal: AbortSignal;
40
+ readonly work: TaskWorkScope;
41
+ }
42
+
43
+ /** Persists model-visible Pi evidence before advancing the relay cursor or retiring a local item, then starts one separate turn. */
44
+ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal, work = createTaskWorkScope(signal, "phased")): Promise<TaskOutboxDeliveryError | undefined> {
45
+ // Explicit phases: intake, presentation, required sends, ACKs, then old-work retry.
46
+ if (!work.phased) throw new TypeError("inbox delivery requires a phased work scope");
47
+ signal = work.signal;
48
+ let outboxError: TaskOutboxDeliveryError | undefined;
49
+ let failure: unknown;
50
+ const receipts: RelayReceipts = { signal, work };
51
+ try {
52
+ // Intake persists and canonicalizes without sending; earlier required sends of this pass are already queued.
53
+ const items = await core.intakeInbox(signal, work);
54
+ // Presentation persists Pi evidence only. Each relay item ends in an ACK, so stop at the remaining budget.
55
+ const budget = work.remainingAcknowledgements();
56
+ const presented: { readonly cursor: string; readonly shipments: readonly TaskShipment[] }[] = [];
57
+ // Shipments queued from here on are this presentation's delivery receipts.
58
+ const receiptsFrom = work.shipments.length;
59
+ try {
60
+ // The first held model-visible item holds every later visible item, so none overtakes it;
61
+ // later control facts and self-authored copies are still archived and ACKed.
62
+ let held = false;
63
+ for (const item of items) {
64
+ if (item.kind === "local") {
65
+ if (held && isPresentedToModel(core, item.event)) continue;
66
+ if (!await presentEvent(pi, core, context, item.event, undefined)) { held = true; continue; }
67
+ core.completeLocalPresentation(item.event.eventId);
68
+ continue;
69
+ }
70
+ if (presented.length >= budget) break;
71
+ const delivery = item.delivery;
72
+ const mark = work.shipments.length;
73
+ if (delivery.envelope.kind !== TaskEnvelopeKind.intent) {
74
+ const event = inboxEvent(delivery);
75
+ if (held && isPresentedToModel(core, event)) continue;
76
+ if (!await presentEvent(pi, core, context, event, receipts)) { held = true; continue; }
77
+ }
78
+ presented.push({ cursor: delivery.cursor, shipments: work.shipments.slice(mark) });
79
+ }
80
+ } catch (error) {
81
+ // Items presented before the failure still ship and ACK; the failure surfaces at the end.
82
+ if (work.signal.aborted) throw error;
83
+ failure = error;
84
+ }
85
+ // Required sends in persist order. A receipt blocked for good is reported and does not hold its ACK.
86
+ await core.shipRequired(signal, work);
87
+ for (const [index, shipment] of work.shipments.entries()) {
88
+ if (shipment.failure === undefined) continue;
89
+ if (index >= receiptsFrom && isBlockedDeliveryFailure(shipment.failure)) outboxError ??= shipment.failure;
90
+ else failure ??= shipment.failure;
91
+ }
92
+ // ACK only items whose receipts were accepted or blocked for good.
93
+ try {
94
+ for (const { cursor, shipments } of presented) {
95
+ if (shipments.some(shipment => !shipment.shipped || (shipment.failure !== undefined && !isBlockedDeliveryFailure(shipment.failure)))) continue;
96
+ await core.acknowledgeRelayDelivery(cursor, signal, work);
97
+ pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor });
98
+ }
99
+ await core.repairAcknowledgements(signal, work);
100
+ } catch (error) {
101
+ if (work.signal.aborted) throw error;
102
+ failure ??= error;
103
+ }
104
+ } finally {
105
+ // Completed, held or failed delivery yields the remaining bounded share to old work.
106
+ if (!work.signal.aborted) try { await core.flushOutbox(work.signal, work); } catch (error) {
107
+ if (!(error instanceof TaskOutboxDeliveryError)) throw error;
108
+ outboxError ??= error;
109
+ }
110
+ }
111
+ // Collected failures surface only after presentation, ACKs and the bounded flush had their share.
112
+ if (failure !== undefined) throw failure;
113
+ return outboxError;
114
+ }
115
+
116
+ /**
117
+ * Idle gate, insertion, rescan, wake and wake evidence for one event. Returns false when presentation is held.
118
+ * Receipts collapse to the highest stage reached in this refresh (the origin derives the stages it implies),
119
+ * plus `pi_insertion` blocked when insertion was not observed.
120
+ */
121
+ async function presentEvent(pi: InboxPi, core: TaskCore, context: InboxContext, event: TaskEvent, receipts: RelayReceipts | undefined): Promise<boolean> {
122
+ const eventDetails = { taskId: event.taskId, eventId: event.eventId };
123
+ const eventKey = key(event.taskId, event.eventId);
124
+ if (!isPresentedToModel(core, event)) {
125
+ // Archive control facts and confirmed self-authored conversations without model insertion or wake,
126
+ // even while Pi is busy. This is evidence only: no task state or delivery queue is reconstructed.
127
+ if (!taskEntryEvidence(context.sessionManager).recorded.has(eventKey)) pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
128
+ return true;
129
+ }
130
+ if (context.hasPendingMessages()) return false;
131
+ const recordReceiver = async () => {
132
+ if (receipts && (event.type === "task.created" || event.type === "task.reassigned")) await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, receipts);
133
+ };
134
+ const recordInsertion = async () => {
135
+ if (receipts) await core.recordInsertion(eventDetails, receipts.signal, receipts.work);
136
+ };
137
+ const incorporated = () => taskEntryEvidence(context.sessionManager).incorporated.get(event.taskId)?.has(event.eventId) === true;
138
+ if (!incorporated()) {
139
+ if (!context.isIdle()) { await recordReceiver(); return false; }
140
+ pi.sendMessage({
141
+ customType: TASK_EVENT_CUSTOM_TYPE,
142
+ content: renderTaskEvent(event),
143
+ display: true,
144
+ details: { ...eventDetails, event },
145
+ }, { triggerTurn: false });
146
+ if (!incorporated()) {
147
+ await recordReceiver();
148
+ if (receipts) await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, receipts);
149
+ return false;
150
+ }
151
+ }
152
+ if (taskEntryEvidence(context.sessionManager).wakes.has(eventKey)) {
153
+ if (receipts) await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, receipts);
154
+ return true;
155
+ }
156
+ if (hasAcknowledgedLocalOriginTerminal(core, event)) { await recordInsertion(); return true; }
157
+ if (!context.isIdle()) { await recordInsertion(); return false; }
158
+ // This synchronous local write closes the ACK/wake race; do not await relay I/O before Pi emits the wake.
159
+ const wakeRequest = receipts && core.recordWakeRequest(eventDetails, receipts.work);
160
+ let wakeAccepted = false;
161
+ try {
162
+ pi.sendMessage({
163
+ customType: TASK_WAKE_CUSTOM_TYPE,
164
+ content: "Process the pending Pi task event.",
165
+ display: false,
166
+ details: eventDetails,
167
+ }, { triggerTurn: true, deliverAs: "followUp" });
168
+ wakeAccepted = taskEntryEvidence(context.sessionManager).wakes.has(eventKey);
169
+ } finally {
170
+ // An unaccepted or throwing wake must not leave a wake_accepted record for the outbox flush.
171
+ if (!wakeAccepted) wakeRequest?.discard();
172
+ }
173
+ if (!wakeAccepted) { await recordInsertion(); return false; }
174
+ if (receipts && wakeRequest) await wakeRequest.flush(receipts.signal);
175
+ return true;
176
+ }
177
+
178
+ /** Persists the receipt; the phased pass ships it before the item's ACK. */
179
+ async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, receipts: RelayReceipts): Promise<void> {
180
+ await core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, receipts.signal, receipts.work);
181
+ }
182
+
183
+ function isBlockedDeliveryFailure(error: unknown): error is TaskOutboxDeliveryError {
184
+ return error instanceof TaskOutboxDeliveryError && !error.retryable && isBlockedOutboxDeliveryCode(error.code);
185
+ }
186
+
187
+ function inboxEvent(delivery: RelayDelivery): TaskEvent {
188
+ try {
189
+ const payload = JSON.parse(delivery.envelope.payload) as unknown;
190
+ if (!isRecord(payload)) throw new Error("not an object");
191
+ if (delivery.envelope.kind === TaskEnvelopeKind.assignment && isTaskEvent(payload.event)) return payload.event;
192
+ if (delivery.envelope.kind === TaskEnvelopeKind.canonicalEvent && isTaskEvent(payload)) return payload;
193
+ } catch { /* normalize malformed opaque payloads to a protocol error */ }
194
+ throw new TaskProtocolError("INVALID_INBOX_EVENT", "relay envelope does not contain a valid model event");
195
+ }
196
+
197
+ /** Model insertion and wake apply; otherwise the event is archived. Throws for an unknown event type. */
198
+ function isPresentedToModel(core: TaskCore, event: TaskEvent): boolean {
199
+ const type = TASK_EVENT_TYPES[event.type];
200
+ if (type === undefined) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
201
+ return type.visible && !core.isSelfAuthoredConversation(event);
202
+ }
203
+
204
+ function hasAcknowledgedLocalOriginTerminal(core: TaskCore, event: TaskEvent): boolean {
205
+ if (TASK_EVENT_TYPES[event.type]?.terminal !== true) return false;
206
+ const task = core.getTask(event.taskId);
207
+ if (task === undefined || task.origin.relay !== core.endpoint.relay || task.origin.id !== core.endpoint.id) return false;
208
+ const terminalIndex = task.events.findIndex((candidate) => candidate.eventId === event.eventId && candidate.type === event.type);
209
+ return terminalIndex >= 0 && task.events.slice(terminalIndex + 1).some((candidate) => candidate.type === "task.parent_acknowledged");
210
+ }
211
+
212
+ function renderTaskEvent(event: TaskEvent): string {
213
+ if (event.type === "task.created") return `## task assignment\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`\n\n${String(event.payload.task ?? "")}`;
214
+ // The successor sees the task itself; earlier history stays in the task record (agent_task_status).
215
+ if (event.type === "task.reassigned") return `## task assignment (reassigned)\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`\n\nthe previous target was lost; earlier task history is in agent_task_status.\n\n${String(event.payload.task ?? "")}`;
216
+ const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";
217
+ const summary = typeof event.payload.summary === "string" ? `\n\n**summary:** ${event.payload.summary}` : "";
218
+ return `## task ${event.type.replace(/^task\./, "").replaceAll("_", " ")}\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`${body}${summary}`;
219
+ }
220
+
221
+ export function incorporatedTaskEvents(entries: readonly unknown[]): readonly TaskEventDetails[] {
222
+ return taskMessageDetails(entries, TASK_EVENT_CUSTOM_TYPE);
223
+ }
224
+
225
+ function taskMessageDetails(entries: readonly unknown[], customType: string): readonly TaskEventDetails[] {
226
+ const events: TaskEventDetails[] = [];
227
+ for (const entry of entries) {
228
+ if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== customType || !isRecord(entry.details) || typeof entry.details.taskId !== "string" || typeof entry.details.eventId !== "string") continue;
229
+ events.push({ taskId: entry.details.taskId, eventId: entry.details.eventId });
230
+ }
231
+ return events;
232
+ }
233
+
234
+ /** Session evidence keys, derived from entries only; never task state. */
235
+ export interface TaskEntryEvidence {
236
+ /** Incorporated `pi-tasks-event` event ids by task id. */
237
+ readonly incorporated: ReadonlyMap<string, ReadonlySet<string>>;
238
+ /** `pi-tasks-event-record` keys. */
239
+ readonly recorded: ReadonlySet<string>;
240
+ /** `pi-tasks-wake` keys. */
241
+ readonly wakes: ReadonlySet<string>;
242
+ }
243
+
244
+ interface EntryMemo extends TaskEntryEvidence {
245
+ readonly incorporated: Map<string, Set<string>>;
246
+ readonly recorded: Set<string>;
247
+ readonly wakes: Set<string>;
248
+ length: number;
249
+ last: unknown;
250
+ }
251
+
252
+ const entryMemos = new WeakMap<object, EntryMemo>();
253
+
254
+ /**
255
+ * Evidence for this session manager, extended with appended entries only. Pi entries are immutable and appended,
256
+ * so an unchanged entry at the last indexed position means nothing before it changed; anything else rescans.
257
+ */
258
+ export function taskEntryEvidence(sessionManager: InboxContext["sessionManager"]): TaskEntryEvidence {
259
+ const entries = sessionManager.getEntries();
260
+ let memo = entryMemos.get(sessionManager);
261
+ if (!memo || entries.length < memo.length || (memo.length > 0 && entries[memo.length - 1] !== memo.last)) {
262
+ memo = { incorporated: new Map(), recorded: new Set(), wakes: new Set(), length: 0, last: undefined };
263
+ entryMemos.set(sessionManager, memo);
264
+ }
265
+ for (let index = memo.length; index < entries.length; index++) indexEntry(memo, entries[index]);
266
+ if (entries.length > memo.length) { memo.length = entries.length; memo.last = entries[entries.length - 1]; }
267
+ return memo;
268
+ }
269
+
270
+ function indexEntry(memo: EntryMemo, entry: unknown): void {
271
+ if (!isRecord(entry)) return;
272
+ const details = entry.type === "custom" ? entry.data : entry.type === "custom_message" ? entry.details : undefined;
273
+ if (!isRecord(details) || typeof details.taskId !== "string" || typeof details.eventId !== "string") return;
274
+ if (entry.type === "custom" && entry.customType === TASK_RECORD_CUSTOM_TYPE) memo.recorded.add(key(details.taskId, details.eventId));
275
+ else if (entry.type === "custom_message" && entry.customType === TASK_WAKE_CUSTOM_TYPE) memo.wakes.add(key(details.taskId, details.eventId));
276
+ else if (entry.type === "custom_message" && entry.customType === TASK_EVENT_CUSTOM_TYPE) {
277
+ const events = memo.incorporated.get(details.taskId) ?? new Set<string>();
278
+ events.add(details.eventId); memo.incorporated.set(details.taskId, events);
279
+ }
280
+ }
281
+
282
+ function isTaskEvent(value: unknown): value is TaskEvent {
283
+ return isRecord(value) && typeof value.eventId === "string" && typeof value.taskId === "string" && typeof value.type === "string" && typeof value.sequence === "string" && isRecord(value.source) && isRecord(value.target) && typeof value.occurredAt === "number" && isRecord(value.payload);
284
+ }
285
+
286
+ function key(taskId: string, eventId: string): string {
287
+ return `${taskId}\u0000${eventId}`;
288
+ }
289
+
290
+ function isRecord(value: unknown): value is Record<string, unknown> {
291
+ return value !== null && typeof value === "object" && !Array.isArray(value);
292
+ }
@@ -0,0 +1,243 @@
1
+ export const TASK_PROTOCOL_VERSION = "pi-tasks/v2" as const;
2
+ export const MAX_RELAY_PAYLOAD_BYTES = 48 * 1024;
3
+ export const INVALID_RELAY_METADATA = "INVALID_RELAY_METADATA";
4
+ export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
5
+ export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
6
+ export const TERMINAL_INTENT_OPERATION = "terminal_intent";
7
+
8
+ export { isBlockedOutboxDeliveryCode } from "./task-errors";
9
+
10
+ export interface TaskEventTypeSpec {
11
+ /** Inserted into the model and woken; otherwise archived as evidence only. */
12
+ readonly visible: boolean;
13
+ /** Ends the task; `status` is the task status it sets. */
14
+ readonly terminal: boolean;
15
+ readonly status?: Exclude<TaskRecord["status"], "active">;
16
+ /** Peer conversation that carries the sender's intent identity. */
17
+ readonly conversational: boolean;
18
+ /** Origin-local delivery evidence, never forwarded to the worker. */
19
+ readonly evidence: boolean;
20
+ /** A receiver may submit it as a raw intent. */
21
+ readonly intent: boolean;
22
+ }
23
+
24
+ const eventType = (flags: Partial<TaskEventTypeSpec>): TaskEventTypeSpec => ({ visible: true, terminal: false, conversational: false, evidence: false, intent: false, ...flags });
25
+
26
+ /** Every canonical task event type this protocol version accepts, with its handling. */
27
+ export const TASK_EVENT_TYPES: Readonly<Record<string, TaskEventTypeSpec>> = {
28
+ "task.created": eventType({}),
29
+ /** Origin moved an active task to its lost target's successor; travels as an assignment carrying the history. */
30
+ "task.reassigned": eventType({}),
31
+ "task.completed": eventType({ terminal: true, status: "completed", intent: true }),
32
+ "task.failed": eventType({ terminal: true, status: "failed", intent: true }),
33
+ "task.cancelled": eventType({ terminal: true, status: "cancelled", intent: true }),
34
+ "task.timed_out": eventType({ terminal: true, status: "timed_out" }),
35
+ "task.information": eventType({ conversational: true, intent: true }),
36
+ "task.question": eventType({ conversational: true, intent: true }),
37
+ "task.answer": eventType({ conversational: true, intent: true }),
38
+ "task.delivery_receipt": eventType({ visible: false, evidence: true, intent: true }),
39
+ "task.parent_acknowledged": eventType({ visible: false }),
40
+ "task.late_terminal": eventType({ visible: false }),
41
+ };
42
+
43
+ export function taskEventTypes(predicate: (type: TaskEventTypeSpec) => boolean): ReadonlySet<string> {
44
+ return new Set(Object.entries(TASK_EVENT_TYPES).flatMap(([type, spec]) => predicate(spec) ? [type] : []));
45
+ }
46
+
47
+ /**
48
+ * Ordered receiver_recorded < pi_inserted < wake_accepted; a later stage implies the earlier ones.
49
+ * Receivers emit the highest stage reached per refresh plus pi_insertion blocked. wake_requested is
50
+ * accepted from 0.1.15 receivers but no longer emitted.
51
+ */
52
+ export const TaskDeliveryStage = {
53
+ receiverRecorded: "receiver_recorded",
54
+ piInsertion: "pi_insertion",
55
+ piInserted: "pi_inserted",
56
+ wakeRequested: "wake_requested",
57
+ wakeAccepted: "wake_accepted",
58
+ } as const;
59
+
60
+ export const TaskDeliveryEvidenceState = {
61
+ confirmed: "confirmed",
62
+ blocked: "blocked",
63
+ } as const;
64
+
65
+ export interface TaskEndpoint {
66
+ readonly relay: string;
67
+ readonly id: string;
68
+ }
69
+
70
+ /** Endpoint-owned transport binding; not task lifecycle or relay replay state. */
71
+ export interface RelayTransportBinding {
72
+ readonly profile: "volatile-v1";
73
+ readonly epoch: string;
74
+ readonly endpoint: TaskEndpoint;
75
+ readonly generation: string;
76
+ readonly callerSession: string;
77
+ readonly url: string;
78
+ readonly reset?: true;
79
+ }
80
+
81
+ /** A provider-defined reference which core resolves without interpreting. */
82
+ export interface RelayTargetReference {
83
+ readonly relay: string;
84
+ readonly reference: string;
85
+ }
86
+
87
+ export interface RelayConnectInput {
88
+ readonly endpoint: TaskEndpoint;
89
+ readonly protocolVersion: string;
90
+ readonly receiveCursor: string;
91
+ }
92
+
93
+ export interface RelayConnection {
94
+ readonly endpoint: TaskEndpoint;
95
+ readonly receiveCursor: string;
96
+ }
97
+
98
+ export const TaskEnvelopeKind = {
99
+ assignment: "assignment",
100
+ intent: "intent",
101
+ canonicalEvent: "canonical_event",
102
+ } as const;
103
+
104
+ export type TaskEnvelopeKind = (typeof TaskEnvelopeKind)[keyof typeof TaskEnvelopeKind];
105
+
106
+ export interface RelayEnvelope {
107
+ readonly envelopeId: string;
108
+ readonly protocolVersion: string;
109
+ readonly source: TaskEndpoint;
110
+ readonly target: TaskEndpoint;
111
+ readonly taskId: string;
112
+ readonly kind: TaskEnvelopeKind;
113
+ /** Immutable transport creation time, persisted with new outbox envelopes.
114
+ * Received/legacy records may omit it; Wolfpack sends require it. */
115
+ readonly createdAt?: string;
116
+ /** Opaque to relays. The endpoint protocol defines its JSON representation. */
117
+ readonly payload: string;
118
+ }
119
+
120
+ export interface RelayAcceptance {
121
+ readonly envelopeId: string;
122
+ }
123
+
124
+ export interface RelayReceiveRequest {
125
+ readonly endpoint: TaskEndpoint;
126
+ readonly cursor: string;
127
+ readonly limit: number;
128
+ }
129
+
130
+ export interface RelayDelivery {
131
+ readonly cursor: string;
132
+ readonly envelope: RelayEnvelope;
133
+ }
134
+
135
+ export interface RelayInboxPage {
136
+ readonly deliveries: readonly RelayDelivery[];
137
+ readonly nextCursor: string;
138
+ readonly hasMore: boolean;
139
+ }
140
+
141
+ export interface RelayDeliveryAck {
142
+ readonly endpoint: TaskEndpoint;
143
+ readonly cursor: string;
144
+ /** Durable cursor-to-envelope binding, for identical ACK retry after reopen. */
145
+ readonly envelopeId?: string;
146
+ }
147
+
148
+ export interface TaskRelay {
149
+ readonly id: string;
150
+ connect(input: RelayConnectInput, signal?: AbortSignal): Promise<RelayConnection>;
151
+ resolve(input: RelayTargetReference, signal?: AbortSignal): Promise<TaskEndpoint>;
152
+ send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance>;
153
+ receive(input: RelayReceiveRequest, signal?: AbortSignal): Promise<RelayInboxPage>;
154
+ /** Acknowledge this delivery only, never an implicit cursor prefix. */
155
+ acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void>;
156
+ }
157
+
158
+ export interface TaskEvent {
159
+ readonly eventId: string;
160
+ readonly taskId: string;
161
+ readonly type: string;
162
+ readonly sequence: string;
163
+ readonly source: TaskEndpoint;
164
+ readonly target: TaskEndpoint;
165
+ readonly occurredAt: number;
166
+ readonly payload: Record<string, unknown>;
167
+ }
168
+
169
+ export interface TaskRecord {
170
+ readonly taskId: string;
171
+ readonly protocolVersion: string;
172
+ readonly origin: TaskEndpoint;
173
+ readonly target: TaskEndpoint;
174
+ readonly task: string;
175
+ readonly createdAt: number;
176
+ readonly expiresAt: number;
177
+ readonly status: "active" | "completed" | "failed" | "cancelled" | "timed_out";
178
+ readonly events: readonly TaskEvent[];
179
+ }
180
+
181
+ export type TerminalTaskIntentType = "task.completed" | "task.failed" | "task.cancelled";
182
+
183
+ export type TaskDeliveryStage = (typeof TaskDeliveryStage)[keyof typeof TaskDeliveryStage];
184
+ export type TaskDeliveryEvidenceState = (typeof TaskDeliveryEvidenceState)[keyof typeof TaskDeliveryEvidenceState];
185
+
186
+ export interface TaskIntent {
187
+ readonly intentId: string;
188
+ readonly taskId: string;
189
+ readonly type: TerminalTaskIntentType | "task.information" | "task.question" | "task.answer" | "task.delivery_receipt";
190
+ readonly payload: Record<string, unknown>;
191
+ }
192
+
193
+ interface TerminalDeliveryIdentity {
194
+ readonly intentId: string;
195
+ readonly intentType: TerminalTaskIntentType;
196
+ readonly envelopeId: string;
197
+ readonly origin: TaskEndpoint;
198
+ }
199
+
200
+ interface TerminalDeliveryPending extends TerminalDeliveryIdentity {
201
+ readonly state: "pending";
202
+ }
203
+
204
+ interface TerminalDeliveryAccepted extends TerminalDeliveryIdentity {
205
+ readonly state: "accepted";
206
+ }
207
+
208
+ interface TerminalDeliveryBlocked extends TerminalDeliveryIdentity {
209
+ readonly state: "delivery_blocked";
210
+ readonly blockedAt: number;
211
+ readonly error: { readonly code: string; readonly retryable: false; readonly details: Readonly<Record<string, unknown>> };
212
+ }
213
+
214
+ export type TerminalDeliveryState =
215
+ | { readonly state: "not_submitted" }
216
+ | TerminalDeliveryPending
217
+ | TerminalDeliveryAccepted
218
+ | TerminalDeliveryBlocked;
219
+
220
+ export interface TaskSnapshot extends TaskRecord {
221
+ readonly terminalDelivery: TerminalDeliveryState;
222
+ }
223
+
224
+ export class TaskProtocolError extends Error {
225
+ readonly code: string;
226
+ readonly retryable: boolean;
227
+ readonly details: Readonly<Record<string, unknown>> | undefined;
228
+
229
+ constructor(code: string, message: string, options: { readonly retryable?: boolean; readonly details?: Readonly<Record<string, unknown>> } = {}) {
230
+ super(message);
231
+ this.name = "TaskProtocolError";
232
+ this.code = code;
233
+ this.retryable = options.retryable ?? true;
234
+ this.details = options.details;
235
+ }
236
+ }
237
+
238
+ export class TaskOutboxDeliveryError extends TaskProtocolError {
239
+ constructor(code: string, message: string, options: { readonly retryable?: boolean; readonly details?: Readonly<Record<string, unknown>> } = {}) {
240
+ super(code, message, options);
241
+ this.name = "TaskOutboxDeliveryError";
242
+ }
243
+ }