@sgtbeatdown/pi-tasks 0.1.14 → 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.
- package/README.md +10 -4
- package/docs/protocol-reference.md +18 -5
- package/package.json +1 -1
- package/src/abortable-sleep.ts +14 -5
- package/src/configured-task-core.ts +8 -4
- package/src/extension.ts +164 -64
- package/src/index.ts +3 -2
- package/src/task-core.ts +475 -137
- package/src/task-errors.ts +111 -0
- package/src/task-inbox.ts +190 -89
- package/src/task-protocol.ts +44 -13
- package/src/task-store.ts +181 -27
- package/src/task-work-scope.ts +98 -0
- package/src/volatile-task-session.ts +30 -8
|
@@ -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,12 +1,13 @@
|
|
|
1
1
|
import type { TaskCore } from "./task-core";
|
|
2
|
-
import {
|
|
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";
|
|
3
5
|
import type { RelayDelivery, TaskEvent } from "./task-protocol";
|
|
4
6
|
|
|
5
7
|
const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
|
|
6
8
|
const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
|
|
7
9
|
const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
|
|
8
10
|
const TASK_RECORD_CUSTOM_TYPE = "pi-tasks-event-record";
|
|
9
|
-
const TERMINAL_EVENT_TYPES = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
|
|
10
11
|
|
|
11
12
|
interface InboxContext {
|
|
12
13
|
readonly isIdle: () => boolean;
|
|
@@ -30,92 +31,157 @@ export interface TaskEventDetails {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
type DeliveryEvidencePayload =
|
|
33
|
-
| { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.
|
|
34
|
+
| { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
|
|
34
35
|
| { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
|
|
35
36
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
|
39
48
|
let outboxError: TaskOutboxDeliveryError | undefined;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
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;
|
|
55
84
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
|
59
91
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
display: true,
|
|
67
|
-
details: { ...eventDetails, event },
|
|
68
|
-
}, { triggerTurn: false });
|
|
69
|
-
incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
70
|
-
if (!incorporated) {
|
|
71
|
-
const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
|
|
72
|
-
outboxError ??= evidenceError;
|
|
73
|
-
return outboxError;
|
|
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 });
|
|
74
98
|
}
|
|
99
|
+
await core.repairAcknowledgements(signal, work);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (work.signal.aborted) throw error;
|
|
102
|
+
failure ??= error;
|
|
75
103
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!wakeAccepted && !wakeSuppressed) {
|
|
82
|
-
if (!context.isIdle()) return outboxError;
|
|
83
|
-
// This synchronous local write closes the ACK/wake race; do not await relay I/O before Pi emits the wake.
|
|
84
|
-
const wakeRequest = core.recordWakeRequest(eventDetails);
|
|
85
|
-
pi.sendMessage({
|
|
86
|
-
customType: TASK_WAKE_CUSTOM_TYPE,
|
|
87
|
-
content: "Process the pending Pi task event.",
|
|
88
|
-
display: false,
|
|
89
|
-
details: eventDetails,
|
|
90
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
91
|
-
wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
|
|
92
|
-
if (!wakeAccepted) return outboxError;
|
|
93
|
-
const wakeRequestError = await settleDeliveryEvidence(() => wakeRequest.flush(signal));
|
|
94
|
-
outboxError ??= wakeRequestError;
|
|
95
|
-
}
|
|
96
|
-
if (wakeAccepted) {
|
|
97
|
-
const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
98
|
-
outboxError ??= wakeAcceptanceError;
|
|
99
|
-
}
|
|
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;
|
|
100
109
|
}
|
|
101
|
-
await core.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
102
|
-
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
|
|
103
110
|
}
|
|
111
|
+
// Collected failures surface only after presentation, ACKs and the bounded flush had their share.
|
|
112
|
+
if (failure !== undefined) throw failure;
|
|
104
113
|
return outboxError;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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;
|
|
112
161
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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();
|
|
118
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);
|
|
119
185
|
}
|
|
120
186
|
|
|
121
187
|
function inboxEvent(delivery: RelayDelivery): TaskEvent {
|
|
@@ -128,16 +194,15 @@ function inboxEvent(delivery: RelayDelivery): TaskEvent {
|
|
|
128
194
|
throw new TaskProtocolError("INVALID_INBOX_EVENT", "relay envelope does not contain a valid model event");
|
|
129
195
|
}
|
|
130
196
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
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);
|
|
137
202
|
}
|
|
138
203
|
|
|
139
204
|
function hasAcknowledgedLocalOriginTerminal(core: TaskCore, event: TaskEvent): boolean {
|
|
140
|
-
if (
|
|
205
|
+
if (TASK_EVENT_TYPES[event.type]?.terminal !== true) return false;
|
|
141
206
|
const task = core.getTask(event.taskId);
|
|
142
207
|
if (task === undefined || task.origin.relay !== core.endpoint.relay || task.origin.id !== core.endpoint.id) return false;
|
|
143
208
|
const terminalIndex = task.events.findIndex((candidate) => candidate.eventId === event.eventId && candidate.type === event.type);
|
|
@@ -146,6 +211,8 @@ function hasAcknowledgedLocalOriginTerminal(core: TaskCore, event: TaskEvent): b
|
|
|
146
211
|
|
|
147
212
|
function renderTaskEvent(event: TaskEvent): string {
|
|
148
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 ?? "")}`;
|
|
149
216
|
const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";
|
|
150
217
|
const summary = typeof event.payload.summary === "string" ? `\n\n**summary:** ${event.payload.summary}` : "";
|
|
151
218
|
return `## task ${event.type.replace(/^task\./, "").replaceAll("_", " ")}\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`${body}${summary}`;
|
|
@@ -164,18 +231,52 @@ function taskMessageDetails(entries: readonly unknown[], customType: string): re
|
|
|
164
231
|
return events;
|
|
165
232
|
}
|
|
166
233
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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;
|
|
171
250
|
}
|
|
172
251
|
|
|
173
|
-
|
|
174
|
-
|
|
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;
|
|
175
268
|
}
|
|
176
269
|
|
|
177
|
-
function
|
|
178
|
-
|
|
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
|
+
}
|
|
179
280
|
}
|
|
180
281
|
|
|
181
282
|
function isTaskEvent(value: unknown): value is TaskEvent {
|
package/src/task-protocol.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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",
|