@sgtbeatdown/pi-tasks 0.1.15 → 0.1.16

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
+ }
package/src/task-inbox.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import type { TaskCore } from "./task-core";
2
2
  import { createTaskWorkScope } from "./task-work-scope";
3
- import type { TaskWorkScope } from "./task-work-scope";
4
- import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
3
+ import type { TaskShipment, TaskWorkScope } from "./task-work-scope";
4
+ import { TASK_EVENT_TYPES, TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
5
5
  import type { RelayDelivery, TaskEvent } from "./task-protocol";
6
6
 
7
7
  const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
8
8
  const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
9
9
  const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
10
10
  const TASK_RECORD_CUSTOM_TYPE = "pi-tasks-event-record";
11
- const TERMINAL_EVENT_TYPES = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
12
11
 
13
12
  interface InboxContext {
14
13
  readonly isIdle: () => boolean;
@@ -32,104 +31,157 @@ export interface TaskEventDetails {
32
31
  }
33
32
 
34
33
  type DeliveryEvidencePayload =
35
- | { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
34
+ | { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
36
35
  | { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
37
36
 
38
- /** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
39
- export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal, work = createTaskWorkScope(signal)): Promise<TaskOutboxDeliveryError | undefined> {
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");
40
47
  signal = work.signal;
41
- work.retryAllowed = false; // Reserve intake, presentation and evidence/ACK ahead of old work.
42
48
  let outboxError: TaskOutboxDeliveryError | undefined;
49
+ let failure: unknown;
50
+ const receipts: RelayReceipts = { signal, work };
43
51
  try {
44
- const deliveries = await core.receive(signal, work);
45
- for (const delivery of deliveries) {
46
- if (delivery.envelope.kind === TaskEnvelopeKind.intent) {
47
- await core.acknowledgeRelayDelivery(delivery.cursor, signal, work);
48
- pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
49
- continue;
50
- }
51
- const event = inboxEvent(delivery);
52
- if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
53
- if (context.hasPendingMessages()) break;
54
- const eventDetails = { taskId: event.taskId, eventId: event.eventId };
55
- const eventKey = key(event.taskId, event.eventId);
56
- const modelVisible = isModelVisible(event.type) && !core.isSelfAuthoredConversation(event);
57
- if (!modelVisible && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
58
- // Archive control facts and confirmed self-authored conversations without model insertion or wake.
59
- // This is evidence only: no task state or delivery queue is reconstructed.
60
- pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
61
- }
62
- if (event.type === "task.created") {
63
- const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal, work);
64
- outboxError ??= evidenceError;
65
- }
66
- let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
67
- if (!incorporated && modelVisible) {
68
- if (!context.isIdle()) break;
69
- pi.sendMessage({
70
- customType: TASK_EVENT_CUSTOM_TYPE,
71
- content: renderTaskEvent(event),
72
- display: true,
73
- details: { ...eventDetails, event },
74
- }, { triggerTurn: false });
75
- incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
76
- if (!incorporated) {
77
- const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal, work);
78
- outboxError ??= evidenceError;
79
- break;
80
- }
81
- }
82
- if (modelVisible) {
83
- const insertionError = await settleDeliveryEvidence(() => core.recordInsertion(eventDetails, signal, work));
84
- outboxError ??= insertionError;
85
- let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
86
- const wakeSuppressed = !wakeAccepted && hasAcknowledgedLocalOriginTerminal(core, event);
87
- if (!wakeAccepted && !wakeSuppressed) {
88
- if (!context.isIdle()) break;
89
- // This synchronous local write closes the ACK/wake race; do not await relay I/O before Pi emits the wake.
90
- const wakeRequest = core.recordWakeRequest(eventDetails, work);
91
- pi.sendMessage({
92
- customType: TASK_WAKE_CUSTOM_TYPE,
93
- content: "Process the pending Pi task event.",
94
- display: false,
95
- details: eventDetails,
96
- }, { triggerTurn: true, deliverAs: "followUp" });
97
- wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
98
- if (!wakeAccepted) break;
99
- const wakeRequestError = await settleDeliveryEvidence(() => wakeRequest.flush(signal));
100
- outboxError ??= wakeRequestError;
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;
101
69
  }
102
- if (wakeAccepted) {
103
- const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal, work);
104
- outboxError ??= wakeAcceptanceError;
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; }
105
77
  }
78
+ presented.push({ cursor: delivery.cursor, shipments: work.shipments.slice(mark) });
106
79
  }
107
- await core.acknowledgeRelayDelivery(delivery.cursor, signal, work);
108
- pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
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;
109
103
  }
110
104
  } finally {
111
- // Completed, held or failed presentation yields the remaining bounded share to old work.
112
- work.retryAllowed = true;
105
+ // Completed, held or failed delivery yields the remaining bounded share to old work.
113
106
  if (!work.signal.aborted) try { await core.flushOutbox(work.signal, work); } catch (error) {
114
107
  if (!(error instanceof TaskOutboxDeliveryError)) throw error;
115
108
  outboxError ??= error;
116
109
  }
117
110
  }
111
+ // Collected failures surface only after presentation, ACKs and the bounded flush had their share.
112
+ if (failure !== undefined) throw failure;
118
113
  return outboxError;
119
114
  }
120
115
 
121
- async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal: AbortSignal, work: TaskWorkScope): Promise<TaskOutboxDeliveryError | undefined> {
122
- return settleDeliveryEvidence(() => core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal, work));
123
- }
124
-
125
- async function settleDeliveryEvidence(operation: () => Promise<void>): Promise<TaskOutboxDeliveryError | undefined> {
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;
126
161
  try {
127
- await operation();
128
- return undefined;
129
- } catch (error) {
130
- if (error instanceof TaskOutboxDeliveryError && !error.retryable && isBlockedOutboxDeliveryCode(error.code)) return error;
131
- throw error;
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();
132
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);
133
185
  }
134
186
 
135
187
  function inboxEvent(delivery: RelayDelivery): TaskEvent {
@@ -142,16 +194,15 @@ function inboxEvent(delivery: RelayDelivery): TaskEvent {
142
194
  throw new TaskProtocolError("INVALID_INBOX_EVENT", "relay envelope does not contain a valid model event");
143
195
  }
144
196
 
145
- function isKnownEvent(type: string): boolean {
146
- return ["task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out", "task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
147
- }
148
-
149
- function isModelVisible(type: string): boolean {
150
- return !["task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
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);
151
202
  }
152
203
 
153
204
  function hasAcknowledgedLocalOriginTerminal(core: TaskCore, event: TaskEvent): boolean {
154
- if (!TERMINAL_EVENT_TYPES.has(event.type)) return false;
205
+ if (TASK_EVENT_TYPES[event.type]?.terminal !== true) return false;
155
206
  const task = core.getTask(event.taskId);
156
207
  if (task === undefined || task.origin.relay !== core.endpoint.relay || task.origin.id !== core.endpoint.id) return false;
157
208
  const terminalIndex = task.events.findIndex((candidate) => candidate.eventId === event.eventId && candidate.type === event.type);
@@ -160,6 +211,8 @@ function hasAcknowledgedLocalOriginTerminal(core: TaskCore, event: TaskEvent): b
160
211
 
161
212
  function renderTaskEvent(event: TaskEvent): string {
162
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 ?? "")}`;
163
216
  const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";
164
217
  const summary = typeof event.payload.summary === "string" ? `\n\n**summary:** ${event.payload.summary}` : "";
165
218
  return `## task ${event.type.replace(/^task\./, "").replaceAll("_", " ")}\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`${body}${summary}`;
@@ -178,18 +231,52 @@ function taskMessageDetails(entries: readonly unknown[], customType: string): re
178
231
  return events;
179
232
  }
180
233
 
181
- function recordedEventKeys(entries: readonly unknown[]): Set<string> {
182
- return new Set(entries.flatMap(entry => isRecord(entry) && entry.type === "custom" && entry.customType === TASK_RECORD_CUSTOM_TYPE
183
- && isRecord(entry.data) && typeof entry.data.taskId === "string" && typeof entry.data.eventId === "string"
184
- ? [key(entry.data.taskId, entry.data.eventId)] : []));
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>;
185
242
  }
186
243
 
187
- function taskMessageKeys(entries: readonly unknown[], customType: string): Set<string> {
188
- return new Set(taskMessageDetails(entries, customType).map((event) => key(event.taskId, event.eventId)));
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;
189
250
  }
190
251
 
191
- function incorporatedEvents(entries: readonly unknown[]): Set<string> {
192
- return taskMessageKeys(entries, TASK_EVENT_CUSTOM_TYPE);
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
+ }
193
280
  }
194
281
 
195
282
  function isTaskEvent(value: unknown): value is TaskEvent {
@@ -5,19 +5,50 @@ export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
5
5
  export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
6
6
  export const TERMINAL_INTENT_OPERATION = "terminal_intent";
7
7
 
8
- const BLOCKED_OUTBOX_DELIVERY_CODES: ReadonlySet<string> = new Set([
9
- "TARGET_NOT_REGISTERED",
10
- INVALID_RELAY_METADATA,
11
- "DELIVERY_UNCONFIRMED",
12
- "ENVELOPE_EXPIRED",
13
- "ENVELOPE_CONFLICT",
14
- "CROSS_RELAY_ENDPOINT",
15
- ]);
16
-
17
- export function isBlockedOutboxDeliveryCode(code: string): boolean {
18
- return BLOCKED_OUTBOX_DELIVERY_CODES.has(code);
19
- }
20
-
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
+ */
21
52
  export const TaskDeliveryStage = {
22
53
  receiverRecorded: "receiver_recorded",
23
54
  piInsertion: "pi_insertion",