@sgtbeatdown/pi-tasks 0.1.5 → 0.1.7

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/src/task-core.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  PARENT_ACKNOWLEDGMENT_OPERATION,
5
5
  TASK_PROTOCOL_VERSION,
6
6
  TERMINAL_INTENT_OPERATION,
7
+ TaskDeliveryEvidenceState,
8
+ TaskDeliveryStage,
7
9
  TaskEnvelopeKind,
8
10
  TaskOutboxDeliveryError,
9
11
  TaskProtocolError,
@@ -21,6 +23,7 @@ import type {
21
23
  import type { TaskStore } from "./task-store";
22
24
 
23
25
  const RECEIVE_PAGE_SIZE = 100;
26
+ const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
24
27
  const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
25
28
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
26
29
  const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
@@ -53,6 +56,10 @@ export interface SubmitIntentInput {
53
56
  readonly payload: Record<string, unknown>;
54
57
  }
55
58
 
59
+ export type SubmitIntentOutcome =
60
+ | { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
61
+ | { readonly authority: "receiver" };
62
+
56
63
  export interface TaskCore {
57
64
  readonly endpoint: TaskEndpoint;
58
65
  connect(signal?: AbortSignal): Promise<void>;
@@ -63,6 +70,7 @@ export interface TaskCore {
63
70
  receive(signal?: AbortSignal): Promise<readonly RelayDelivery[]>;
64
71
  acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal): Promise<void>;
65
72
  submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
73
+ submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome>;
66
74
  recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
67
75
  evaluateTimeouts(signal?: AbortSignal): Promise<void>;
68
76
  acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
@@ -76,6 +84,36 @@ type ReceivedEnvelope =
76
84
  export function createTaskCore(options: TaskCoreOptions): TaskCore {
77
85
  const clock = options.clock ?? { now: (): number => Date.now() };
78
86
  const ids = options.ids ?? (() => crypto.randomUUID());
87
+ const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome> => {
88
+ if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
89
+ let persistedEnvelopeIds: readonly string[] = [];
90
+ let outcome: SubmitIntentOutcome | undefined;
91
+ let receiverTerminal = false;
92
+ let originCancellation = false;
93
+ const evidence = deliveryEvidence(input);
94
+ options.store.transaction(() => {
95
+ const task = requiredTask(options.store, input.taskId);
96
+ receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
97
+ originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
98
+ const persisted = persistIntent(options, clock.now, ids, task, input, evidence === undefined ? undefined : deliveryEvidenceOperation(evidence));
99
+ persistedEnvelopeIds = persisted.envelopeIds;
100
+ outcome = persisted.outcome;
101
+ });
102
+ if (evidence !== undefined) {
103
+ await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
104
+ } else {
105
+ try {
106
+ await flush(options, signal, new Set(persistedEnvelopeIds));
107
+ } catch (error) {
108
+ if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
109
+ throw error;
110
+ }
111
+ if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
112
+ }
113
+ if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
114
+ if (outcome === undefined) throw new TaskProtocolError("INVALID_INTENT", "task intent outcome was not persisted");
115
+ return outcome;
116
+ };
79
117
 
80
118
  return {
81
119
  endpoint: options.endpoint,
@@ -138,35 +176,21 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
138
176
  await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor }, signal);
139
177
  options.store.transaction(() => { options.store.setReceiveCursor(cursor); });
140
178
  },
141
- async submitIntent(input, signal) {
142
- if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
143
- let persistedEnvelopeIds: readonly string[] = [];
144
- let receiverTerminal = false;
145
- let originCancellation = false;
146
- options.store.transaction(() => {
147
- const task = requiredTask(options.store, input.taskId);
148
- receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
149
- originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
150
- persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, input);
151
- });
152
- try {
153
- await flush(options, signal, new Set(persistedEnvelopeIds));
154
- } catch (error) {
155
- if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
156
- throw error;
157
- }
158
- if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
159
- if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
160
- },
179
+ async submitIntent(input, signal) { await submitIntentWithOutcome(input, signal); },
180
+ submitIntentWithOutcome,
161
181
  async recordInsertion(input, signal) {
162
182
  const task = requiredTask(options.store, input.taskId);
183
+ const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.piInserted, state: TaskDeliveryEvidenceState.confirmed };
163
184
  let persistedEnvelopeIds: readonly string[] = [];
164
185
  options.store.transaction(() => {
165
- if (options.store.putInsertionReceipt(input.taskId, input.eventId)) {
166
- persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, { taskId: input.taskId, type: "task.delivery_receipt", payload: { eventId: input.eventId } });
167
- }
186
+ options.store.putInsertionReceipt(input.taskId, input.eventId);
187
+ persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
188
+ taskId: input.taskId,
189
+ type: "task.delivery_receipt",
190
+ payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
191
+ }, deliveryEvidenceOperation(evidence)).envelopeIds;
168
192
  });
169
- if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
193
+ await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
170
194
  },
171
195
  async evaluateTimeouts(signal) {
172
196
  const persistedEnvelopeIds: string[] = [];
@@ -175,7 +199,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
175
199
  options.store.transaction(() => {
176
200
  const task = options.store.getTask(candidate.taskId);
177
201
  if (!task || !sameEndpoint(task.origin, options.endpoint) || TERMINAL_STATUSES.has(task.status) || task.expiresAt > clock.now()) return;
178
- persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out"));
202
+ persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out").envelopeIds);
179
203
  });
180
204
  }
181
205
  if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
@@ -186,7 +210,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
186
210
  const task = requiredTask(options.store, taskId);
187
211
  if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
188
212
  if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
189
- persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION);
213
+ persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION).envelopeIds;
190
214
  });
191
215
  await flush(options, signal, new Set(persistedEnvelopeIds));
192
216
  },
@@ -244,42 +268,80 @@ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, id
244
268
  options.store.appendEvent(received.event);
245
269
  return [];
246
270
  }
247
- if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type);
271
+ if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type).envelopeIds;
248
272
  if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
249
273
  return [];
250
274
  }
251
275
 
252
- function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput): readonly string[] {
276
+ interface PersistedIntent {
277
+ readonly envelopeIds: readonly string[];
278
+ readonly outcome: SubmitIntentOutcome;
279
+ }
280
+
281
+ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
253
282
  if (sameEndpoint(options.endpoint, task.origin)) {
254
- const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : undefined;
255
- return canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
283
+ const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
284
+ const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
285
+ return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } } };
256
286
  }
257
287
  const envelopeId = ids();
258
288
  const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
259
- if (TERMINAL_EVENTS.has(input.type)) {
289
+ const operation = TERMINAL_EVENTS.has(input.type) ? TERMINAL_INTENT_OPERATION : reservedOperation;
290
+ if (operation !== undefined) {
260
291
  const reservation = options.store.reserveTaskOperation({
261
292
  taskId: input.taskId,
262
- operation: TERMINAL_INTENT_OPERATION,
293
+ operation,
263
294
  logicalId: intent.intentId,
264
295
  logicalType: intent.type,
265
296
  envelopeIds: [envelopeId],
266
297
  });
267
298
  if (!reservation.created) {
268
- if (reservation.record.logicalType !== intent.type) {
299
+ if (operation === TERMINAL_INTENT_OPERATION && reservation.record.logicalType !== intent.type) {
269
300
  throw new TaskProtocolError("TERMINAL_INTENT_CONFLICT", "terminal task intent conflicts with the existing terminal action", {
270
301
  retryable: false,
271
302
  details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
272
303
  });
273
304
  }
274
- return reservation.record.envelopeIds;
305
+ return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" } };
275
306
  }
276
307
  }
277
308
  options.store.putIntent(intent.intentId, input.taskId, envelopeId);
278
309
  options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
279
- return [envelopeId];
310
+ return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
311
+ }
312
+
313
+ interface DeliveryEvidence {
314
+ readonly eventId: string;
315
+ readonly stage: TaskDeliveryStage;
316
+ readonly state: TaskDeliveryEvidenceState;
317
+ readonly retryable?: true;
318
+ }
319
+
320
+ function deliveryEvidence(input: SubmitIntentInput): DeliveryEvidence | undefined {
321
+ if (input.type !== "task.delivery_receipt" || typeof input.payload.eventId !== "string") return undefined;
322
+ const stage = input.payload.stage;
323
+ const state = input.payload.state;
324
+ if (stage === TaskDeliveryStage.piInsertion && state === TaskDeliveryEvidenceState.blocked && input.payload.retryable === true) {
325
+ return { eventId: input.payload.eventId, stage, state, retryable: true };
326
+ }
327
+ if ((stage === TaskDeliveryStage.receiverPersisted || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
328
+ && state === TaskDeliveryEvidenceState.confirmed) {
329
+ return { eventId: input.payload.eventId, stage, state };
330
+ }
331
+ return undefined;
332
+ }
333
+
334
+ function deliveryEvidenceOperation(input: DeliveryEvidence): string {
335
+ return `${DELIVERY_EVIDENCE_OPERATION}:${input.eventId}:${input.stage}:${input.state}`;
280
336
  }
281
337
 
282
- function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): readonly string[] {
338
+ interface Canonicalization {
339
+ readonly envelopeIds: readonly string[];
340
+ readonly eventType: string;
341
+ readonly reused: boolean;
342
+ }
343
+
344
+ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): Canonicalization {
283
345
  const terminal = TERMINAL_EVENTS.has(requestedType);
284
346
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
285
347
  const sequence = String(task.events.length + 1);
@@ -289,13 +351,23 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
289
351
  const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
290
352
  if (operation !== undefined) {
291
353
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
292
- if (!reservation.created) return reservation.record.envelopeIds;
354
+ if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
293
355
  }
294
356
  options.store.appendEvent(canonical);
295
357
  if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
296
358
  options.store.putOutbox(targetEnvelope);
297
359
  if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
298
- return persistedEnvelopeIds;
360
+ return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
361
+ }
362
+
363
+ async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSignal | undefined, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): Promise<void> {
364
+ try {
365
+ if (envelopeIds.length > 0) await flush(options, signal, new Set(envelopeIds));
366
+ } catch (error) {
367
+ throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
368
+ throw error;
369
+ }
370
+ throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
299
371
  }
300
372
 
301
373
  async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined, reportedEnvelopeIds?: ReadonlySet<string>): Promise<void> {
@@ -359,6 +431,24 @@ function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: st
359
431
  });
360
432
  }
361
433
 
434
+ function throwIfDeliveryEvidenceBlocked(store: TaskStore, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): void {
435
+ const blocked = store.quarantinedOutbox().find((record) => envelopeIds.includes(record.envelope.envelopeId));
436
+ if (blocked === undefined) return;
437
+ throw new TaskOutboxDeliveryError(blocked.errorCode, "task delivery evidence is blocked", {
438
+ retryable: false,
439
+ details: {
440
+ ...blocked.details,
441
+ taskId,
442
+ eventId: evidence.eventId,
443
+ stage: evidence.stage,
444
+ state: evidence.state,
445
+ envelopeId: blocked.envelope.envelopeId,
446
+ target: blocked.envelope.target,
447
+ blockedAt: blocked.quarantinedAt,
448
+ },
449
+ });
450
+ }
451
+
362
452
  function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void {
363
453
  const delivery = requiredTask(store, taskId).terminalDelivery;
364
454
  if (delivery.state !== "delivery_blocked") return;
package/src/task-inbox.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  import type { TaskCore } from "./task-core";
2
- import { TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
2
+ import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
3
3
  import type { RelayDelivery, TaskEvent } from "./task-protocol";
4
4
 
5
5
  const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
6
+ const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
6
7
  const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
7
8
 
8
9
  interface InboxContext {
10
+ readonly isIdle: () => boolean;
9
11
  readonly hasPendingMessages: () => boolean;
10
12
  readonly sessionManager: { readonly getEntries: () => readonly unknown[] };
11
13
  }
12
14
 
13
15
  interface InboxPi {
14
- sendMessage(message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails }, options: { readonly triggerTurn: true; readonly deliverAs: "followUp" }): void;
16
+ sendMessage(
17
+ message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails },
18
+ options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
19
+ ): void;
15
20
  appendEntry(customType: string, data: { readonly cursor: string }): void;
16
21
  }
17
22
 
@@ -20,8 +25,12 @@ export interface TaskEventDetails {
20
25
  readonly eventId: string;
21
26
  }
22
27
 
23
- /** Inserts durable Pi evidence before allowing the relay mailbox cursor to advance. */
24
- export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, pendingInsertions: Set<string>, signal?: AbortSignal): Promise<void> {
28
+ type DeliveryEvidencePayload =
29
+ | { readonly stage: typeof TaskDeliveryStage.receiverPersisted | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
30
+ | { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
31
+
32
+ /** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
33
+ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<void> {
25
34
  if (context.hasPendingMessages()) return;
26
35
  const deliveries = await core.receive(signal);
27
36
  for (const delivery of deliveries) {
@@ -33,28 +42,52 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
33
42
  const event = inboxEvent(delivery);
34
43
  if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
35
44
  if (context.hasPendingMessages()) return;
45
+ const eventDetails = { taskId: event.taskId, eventId: event.eventId };
36
46
  const eventKey = key(event.taskId, event.eventId);
37
- const incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
38
- if (incorporated) pendingInsertions.delete(eventKey);
47
+ if (event.type === "task.created") {
48
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverPersisted, state: TaskDeliveryEvidenceState.confirmed }, signal);
49
+ }
50
+ let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
39
51
  if (!incorporated && isModelVisible(event.type)) {
40
- if (!pendingInsertions.has(eventKey)) {
52
+ if (!context.isIdle()) return;
53
+ pi.sendMessage({
54
+ customType: TASK_EVENT_CUSTOM_TYPE,
55
+ content: renderTaskEvent(event),
56
+ display: true,
57
+ details: eventDetails,
58
+ }, { triggerTurn: false });
59
+ incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
60
+ if (!incorporated) {
61
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
62
+ return;
63
+ }
64
+ }
65
+ if (isModelVisible(event.type)) {
66
+ await core.recordInsertion(eventDetails, signal);
67
+ let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
68
+ if (!wakeAccepted) {
69
+ if (!context.isIdle()) return;
70
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed }, signal);
41
71
  pi.sendMessage({
42
- customType: TASK_EVENT_CUSTOM_TYPE,
43
- content: renderTaskEvent(event),
44
- display: true,
45
- details: { taskId: event.taskId, eventId: event.eventId },
72
+ customType: TASK_WAKE_CUSTOM_TYPE,
73
+ content: "Process the pending Pi task event.",
74
+ display: false,
75
+ details: eventDetails,
46
76
  }, { triggerTurn: true, deliverAs: "followUp" });
47
- pendingInsertions.add(eventKey);
77
+ wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
78
+ if (!wakeAccepted) return;
48
79
  }
49
- if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey)) return;
50
- pendingInsertions.delete(eventKey);
80
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
51
81
  }
52
- if (isModelVisible(event.type)) await core.recordInsertion({ taskId: event.taskId, eventId: event.eventId }, signal);
53
82
  await core.acknowledgeRelayDelivery(delivery.cursor, signal);
54
83
  pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
55
84
  }
56
85
  }
57
86
 
87
+ async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<void> {
88
+ await core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal);
89
+ }
90
+
58
91
  function inboxEvent(delivery: RelayDelivery): TaskEvent {
59
92
  try {
60
93
  const payload = JSON.parse(delivery.envelope.payload) as unknown;
@@ -81,16 +114,24 @@ function renderTaskEvent(event: TaskEvent): string {
81
114
  }
82
115
 
83
116
  export function incorporatedTaskEvents(entries: readonly unknown[]): readonly TaskEventDetails[] {
117
+ return taskMessageDetails(entries, TASK_EVENT_CUSTOM_TYPE);
118
+ }
119
+
120
+ function taskMessageDetails(entries: readonly unknown[], customType: string): readonly TaskEventDetails[] {
84
121
  const events: TaskEventDetails[] = [];
85
122
  for (const entry of entries) {
86
- if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== TASK_EVENT_CUSTOM_TYPE || !isRecord(entry.details) || typeof entry.details.taskId !== "string" || typeof entry.details.eventId !== "string") continue;
123
+ if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== customType || !isRecord(entry.details) || typeof entry.details.taskId !== "string" || typeof entry.details.eventId !== "string") continue;
87
124
  events.push({ taskId: entry.details.taskId, eventId: entry.details.eventId });
88
125
  }
89
126
  return events;
90
127
  }
91
128
 
129
+ function taskMessageKeys(entries: readonly unknown[], customType: string): Set<string> {
130
+ return new Set(taskMessageDetails(entries, customType).map((event) => key(event.taskId, event.eventId)));
131
+ }
132
+
92
133
  function incorporatedEvents(entries: readonly unknown[]): Set<string> {
93
- return new Set(incorporatedTaskEvents(entries).map((event) => key(event.taskId, event.eventId)));
134
+ return taskMessageKeys(entries, TASK_EVENT_CUSTOM_TYPE);
94
135
  }
95
136
 
96
137
  function isTaskEvent(value: unknown): value is TaskEvent {
@@ -4,6 +4,19 @@ export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
4
4
  export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
5
5
  export const TERMINAL_INTENT_OPERATION = "terminal_intent";
6
6
 
7
+ export const TaskDeliveryStage = {
8
+ receiverPersisted: "receiver_persisted",
9
+ piInsertion: "pi_insertion",
10
+ piInserted: "pi_inserted",
11
+ wakeRequested: "wake_requested",
12
+ wakeAccepted: "wake_accepted",
13
+ } as const;
14
+
15
+ export const TaskDeliveryEvidenceState = {
16
+ confirmed: "confirmed",
17
+ blocked: "blocked",
18
+ } as const;
19
+
7
20
  export interface TaskEndpoint {
8
21
  readonly relay: string;
9
22
  readonly id: string;
@@ -105,6 +118,9 @@ export interface TaskRecord {
105
118
 
106
119
  export type TerminalTaskIntentType = "task.completed" | "task.failed" | "task.cancelled";
107
120
 
121
+ export type TaskDeliveryStage = (typeof TaskDeliveryStage)[keyof typeof TaskDeliveryStage];
122
+ export type TaskDeliveryEvidenceState = (typeof TaskDeliveryEvidenceState)[keyof typeof TaskDeliveryEvidenceState];
123
+
108
124
  export interface TaskIntent {
109
125
  readonly intentId: string;
110
126
  readonly taskId: string;
package/src/task-store.ts CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
 
5
5
  import { openSqliteDatabase } from "./sqlite-database";
6
6
  import type { SqliteDatabase } from "./sqlite-database";
7
- import { PARENT_ACKNOWLEDGMENT_OPERATION, TERMINAL_INTENT_OPERATION } from "./task-protocol";
7
+ import { TERMINAL_INTENT_OPERATION } from "./task-protocol";
8
8
  import type { RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
9
9
 
10
10
  const SCHEMA_VERSION = 5;
@@ -71,8 +71,13 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
71
71
  const path = options.path ?? join(homedir(), ".pi", "tasks", "v2", "tasks.sqlite");
72
72
  preparePath(path);
73
73
  const database = openSqliteDatabase(path);
74
- database.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
75
- migrate(database);
74
+ try {
75
+ initializeSchema(database);
76
+ database.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
77
+ } catch (error) {
78
+ database.close();
79
+ throw error;
80
+ }
76
81
 
77
82
  const store: TaskStore = {
78
83
  transaction(operation) {
@@ -213,17 +218,6 @@ interface TaskOperationRow {
213
218
  readonly envelope_ids: string;
214
219
  }
215
220
 
216
- interface LegacyIntentRow {
217
- readonly intent_id: string;
218
- readonly task_id: string;
219
- readonly envelope_id: string;
220
- }
221
-
222
- interface LegacyEnvelopeRow {
223
- readonly envelope_id: string;
224
- readonly envelope: string;
225
- }
226
-
227
221
  interface EventRow {
228
222
  readonly event_id: string;
229
223
  readonly task_id: string;
@@ -237,10 +231,11 @@ interface EventRow {
237
231
  readonly payload: string;
238
232
  }
239
233
 
240
- function migrate(database: SqliteDatabase): void {
234
+ function initializeSchema(database: SqliteDatabase): void {
241
235
  const version = database.query("PRAGMA user_version").get() as { readonly user_version: number };
242
- if (version.user_version > SCHEMA_VERSION) throw new Error("task store schema is newer than this pi-tasks version");
243
- if (version.user_version === 0) {
236
+ if (version.user_version === SCHEMA_VERSION) return;
237
+ if (version.user_version !== 0) throw new Error("task store schema version is unsupported");
238
+ database.transaction(() => {
244
239
  database.exec(`
245
240
  CREATE TABLE tasks (task_id TEXT PRIMARY KEY, protocol_version TEXT NOT NULL, origin_relay TEXT NOT NULL, origin_id TEXT NOT NULL, target_relay TEXT NOT NULL, target_id TEXT NOT NULL, task TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, status TEXT NOT NULL);
246
241
  CREATE TABLE events (event_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id), type TEXT NOT NULL, sequence TEXT NOT NULL, source_relay TEXT NOT NULL, source_id TEXT NOT NULL, target_relay TEXT NOT NULL, target_id TEXT NOT NULL, occurred_at INTEGER NOT NULL, payload TEXT NOT NULL);
@@ -248,137 +243,29 @@ function migrate(database: SqliteDatabase): void {
248
243
  CREATE TABLE inbox (envelope_id TEXT PRIMARY KEY, cursor TEXT NOT NULL, envelope TEXT NOT NULL);
249
244
  CREATE TABLE intents (intent_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, envelope_id TEXT NOT NULL);
250
245
  CREATE TABLE relay_state (name TEXT PRIMARY KEY, value TEXT NOT NULL);
251
- PRAGMA user_version = 1;
252
- `);
253
- }
254
- if (version.user_version <= 1) {
255
- database.exec(`
256
- CREATE INDEX IF NOT EXISTS tasks_origin_expiry ON tasks (origin_relay, origin_id, status, expires_at);
257
- CREATE INDEX IF NOT EXISTS inbox_cursor ON inbox (cursor);
258
- PRAGMA user_version = 2;
259
- `);
260
- }
261
- if (version.user_version <= 2) {
262
- database.exec(`
263
246
  CREATE TABLE insertion_receipts (task_id TEXT NOT NULL REFERENCES tasks(task_id), event_id TEXT NOT NULL, PRIMARY KEY (task_id, event_id));
264
- PRAGMA user_version = 3;
265
- `);
266
- }
267
- if (version.user_version <= 3) {
268
- database.exec(`CREATE TABLE IF NOT EXISTS outbox_quarantine (
269
- envelope_id TEXT PRIMARY KEY,
270
- envelope TEXT NOT NULL,
271
- reason TEXT NOT NULL,
272
- quarantined_at INTEGER NOT NULL,
273
- error_code TEXT NOT NULL DEFAULT 'UNKNOWN',
274
- details TEXT NOT NULL DEFAULT '{}',
275
- prior_state TEXT NOT NULL DEFAULT 'pending'
276
- );`);
277
- ensureColumn(database, "outbox_quarantine", "envelope", "TEXT NOT NULL DEFAULT '{}'");
278
- ensureColumn(database, "outbox_quarantine", "reason", "TEXT NOT NULL DEFAULT 'operator quarantine'");
279
- ensureColumn(database, "outbox_quarantine", "quarantined_at", "INTEGER NOT NULL DEFAULT 0");
280
- ensureColumn(database, "outbox_quarantine", "error_code", "TEXT NOT NULL DEFAULT 'UNKNOWN'");
281
- ensureColumn(database, "outbox_quarantine", "details", "TEXT NOT NULL DEFAULT '{}'");
282
- ensureColumn(database, "outbox_quarantine", "prior_state", "TEXT NOT NULL DEFAULT 'pending'");
283
- database.exec("PRAGMA user_version = 4;");
284
- }
285
- if (version.user_version <= 4) {
286
- database.transaction(() => {
287
- database.exec(`CREATE TABLE IF NOT EXISTS task_operations (
247
+ CREATE TABLE outbox_quarantine (
248
+ envelope_id TEXT PRIMARY KEY,
249
+ envelope TEXT NOT NULL,
250
+ reason TEXT NOT NULL,
251
+ quarantined_at INTEGER NOT NULL,
252
+ error_code TEXT NOT NULL,
253
+ details TEXT NOT NULL,
254
+ prior_state TEXT NOT NULL
255
+ );
256
+ CREATE TABLE task_operations (
288
257
  task_id TEXT NOT NULL REFERENCES tasks(task_id),
289
258
  operation TEXT NOT NULL,
290
259
  logical_id TEXT NOT NULL,
291
260
  logical_type TEXT NOT NULL,
292
261
  envelope_ids TEXT NOT NULL,
293
262
  PRIMARY KEY (task_id, operation)
294
- );`);
295
- backfillTaskOperations(database);
296
- database.exec("PRAGMA user_version = 5;");
297
- })();
298
- }
299
- }
300
-
301
- function backfillTaskOperations(database: SqliteDatabase): void {
302
- const envelopes = legacyEnvelopes(database);
303
- const terminalTasks = new Set<string>();
304
- const intents = database.query("SELECT intent_id, task_id, envelope_id FROM intents ORDER BY rowid").all() as LegacyIntentRow[];
305
- for (const intent of intents) {
306
- if (terminalTasks.has(intent.task_id)) continue;
307
- const serializedEnvelope = envelopes.get(intent.envelope_id);
308
- if (!serializedEnvelope) throw new Error(`legacy intent ${intent.intent_id} has no durable envelope`);
309
- const envelope = legacyEnvelope(intent.envelope_id, serializedEnvelope);
310
- const payload = legacyEnvelopePayload(envelope);
311
- if (envelope.kind !== "intent" || envelope.taskId !== intent.task_id || !isRecord(payload) || payload.intentId !== intent.intent_id || payload.taskId !== intent.task_id || typeof payload.type !== "string") {
312
- throw new Error(`legacy intent ${intent.intent_id} has malformed structured state`);
313
- }
314
- if (!isTerminalIntentType(payload.type)) continue;
315
- insertTaskOperation(database, { taskId: intent.task_id, operation: TERMINAL_INTENT_OPERATION, logicalId: intent.intent_id, logicalType: payload.type, envelopeIds: [intent.envelope_id] });
316
- terminalTasks.add(intent.task_id);
317
- }
318
-
319
- const acknowledgments = database.query("SELECT * FROM events WHERE type = 'task.parent_acknowledged' ORDER BY task_id, CAST(sequence AS INTEGER), rowid").all() as EventRow[];
320
- const acknowledgedTasks = new Set<string>();
321
- for (const acknowledgment of acknowledgments) {
322
- if (acknowledgedTasks.has(acknowledgment.task_id)) continue;
323
- const envelopeIds = [...envelopes]
324
- .flatMap(([envelopeId, serializedEnvelope]) => {
325
- const envelope = optionalLegacyEnvelope(envelopeId, serializedEnvelope);
326
- return envelope?.kind === "canonical_event" && envelope.taskId === acknowledgment.task_id && eventIdFromLegacyEnvelope(envelope) === acknowledgment.event_id ? [envelopeId] : [];
327
- })
328
- .sort();
329
- if (envelopeIds.length === 0) throw new Error(`legacy parent acknowledgment ${acknowledgment.event_id} has no durable envelope`);
330
- insertTaskOperation(database, { taskId: acknowledgment.task_id, operation: PARENT_ACKNOWLEDGMENT_OPERATION, logicalId: acknowledgment.event_id, logicalType: acknowledgment.type, envelopeIds });
331
- acknowledgedTasks.add(acknowledgment.task_id);
332
- }
333
- }
334
-
335
- function legacyEnvelopes(database: SqliteDatabase): Map<string, string> {
336
- const envelopes = new Map<string, string>();
337
- const live = database.query("SELECT envelope_id, envelope FROM outbox ORDER BY rowid").all() as LegacyEnvelopeRow[];
338
- const quarantined = database.query("SELECT envelope_id, envelope FROM outbox_quarantine ORDER BY rowid").all() as LegacyEnvelopeRow[];
339
- for (const row of [...live, ...quarantined]) {
340
- if (row.envelope_id.length === 0) throw new Error("legacy durable envelope identity is malformed");
341
- if (!envelopes.has(row.envelope_id)) envelopes.set(row.envelope_id, row.envelope);
342
- }
343
- return envelopes;
344
- }
345
-
346
- function legacyEnvelope(envelopeId: string, serializedEnvelope: string): RelayEnvelope {
347
- const envelope = parseEnvelope(serializedEnvelope);
348
- if (envelope.envelopeId !== envelopeId) throw new Error(`legacy envelope ${envelopeId} identity is malformed`);
349
- return envelope;
350
- }
351
-
352
- function optionalLegacyEnvelope(envelopeId: string, serializedEnvelope: string): RelayEnvelope | undefined {
353
- try {
354
- return legacyEnvelope(envelopeId, serializedEnvelope);
355
- } catch {
356
- // Unrelated operator quarantine rows are audit data, not lifecycle state.
357
- return undefined;
358
- }
359
- }
360
-
361
- function legacyEnvelopePayload(envelope: RelayEnvelope): unknown {
362
- try {
363
- return JSON.parse(envelope.payload) as unknown;
364
- } catch {
365
- throw new Error(`legacy envelope ${envelope.envelopeId} payload is malformed`);
366
- }
367
- }
368
-
369
- function eventIdFromLegacyEnvelope(envelope: RelayEnvelope): string | undefined {
370
- const payload = legacyEnvelopePayload(envelope);
371
- return isRecord(payload) && typeof payload.eventId === "string" ? payload.eventId : undefined;
372
- }
373
-
374
- function insertTaskOperation(database: SqliteDatabase, input: TaskOperationRecord): void {
375
- database.query(`INSERT INTO task_operations (task_id, operation, logical_id, logical_type, envelope_ids)
376
- VALUES (?, ?, ?, ?, ?) ON CONFLICT(task_id, operation) DO NOTHING`).run(input.taskId, input.operation, input.logicalId, input.logicalType, JSON.stringify(input.envelopeIds));
377
- }
378
-
379
- function ensureColumn(database: SqliteDatabase, table: string, name: string, definition: string): void {
380
- const columns = database.query(`PRAGMA table_info(${table})`).all() as Array<{ readonly name: string }>;
381
- if (!columns.some((column) => column.name === name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
263
+ );
264
+ CREATE INDEX tasks_origin_expiry ON tasks (origin_relay, origin_id, status, expires_at);
265
+ CREATE INDEX inbox_cursor ON inbox (cursor);
266
+ PRAGMA user_version = 5;
267
+ `);
268
+ })();
382
269
  }
383
270
 
384
271
  function preparePath(path: string): void {