@sgtbeatdown/pi-tasks 0.1.11 → 0.1.13
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/package.json +1 -1
- package/skills/wolfpack-pi-task-delegation/SKILL.md +2 -2
- package/src/configured-task-core.ts +14 -5
- package/src/extension.ts +12 -4
- package/src/task-core.ts +33 -9
- package/src/task-inbox.ts +47 -16
- package/src/task-protocol.ts +13 -0
- package/src/volatile-task-session.ts +6 -1
package/package.json
CHANGED
|
@@ -74,8 +74,8 @@ Assignment completion ends the assigned task, not the reusable role session. Whe
|
|
|
74
74
|
1. create a role session through the readiness path above, or select an existing role by stable ID and verify its structured liveness, canonical project, Pi harness, and registered `taskEndpoint`. Never use terminal output as readiness evidence.
|
|
75
75
|
2. call `agent_task_send` with the opaque endpoint and concise instructions in `task`. The tool returns after relay acceptance only, not Pi insertion or model execution. If submission reports a retryable delivery error after local persistence, use the structured task ID from the error and inspect it rather than creating an unrelated replacement.
|
|
76
76
|
3. after `agent_task_send`, continue useful independent coordinator work. When none remains, yield the current turn and let structured task follow-up wake the parent; do not poll `agent_task_status` or `agent_task_inbox` merely to wait. Reserve those tools for concrete progress evidence or recovery, and call `agent_task_wait` only when the user explicitly asks to block.
|
|
77
|
-
4. use `agent_task_message` for `question`, `answer`, or `information` flow within the active endpoint lifetime. Do not use rendered text, terminal output, or logs as lifecycle evidence.
|
|
78
|
-
5. assignees call
|
|
77
|
+
4. while continuing work or asking for input, use `agent_task_message` for intermediate `question`, `answer`, or `information` flow within the active endpoint lifetime. Do not use rendered text, terminal output, or logs as lifecycle evidence.
|
|
78
|
+
5. assignees call exactly one final `agent_task_done` with the assigned task ID, terminal status, and concise completion or blocker summary, plus optional structured result, error, and artifact declarations. Intermediate messages remain allowed; do not send a separate terminal information message before `agent_task_done`. Report source modifications in `result.changedFiles`; artifacts are receiver-project-relative regular files for a parent to inspect, not changed-file lists: `{ "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }`. No prose completion afterward.
|
|
79
79
|
6. the parent independently verifies files, diff, tests, and artifacts. Then call `agent_task_ack({ taskId })` for that one terminal task and explicitly retain or close the spawned role session.
|
|
80
80
|
|
|
81
81
|
## v2 delivery and recovery
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TaskCore } from "./task-core";
|
|
1
|
+
import type { TaskCore, WakeRequest } from "./task-core";
|
|
2
2
|
import { TaskProtocolError } from "./task-protocol";
|
|
3
3
|
import { createTaskStore } from "./task-store";
|
|
4
4
|
import { createVolatileTaskSession } from "./volatile-task-session";
|
|
@@ -36,6 +36,11 @@ export async function createConfiguredTaskCore(options: ConfiguredTaskCoreOption
|
|
|
36
36
|
const active = new Set<Promise<unknown>>();
|
|
37
37
|
let closed = false, closing: Promise<void> | undefined;
|
|
38
38
|
const owned = { ...core } as OwnedTaskCore;
|
|
39
|
+
const track = <T>(result: Promise<T>): Promise<T> => {
|
|
40
|
+
active.add(result);
|
|
41
|
+
void result.then(() => active.delete(result), () => active.delete(result));
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
39
44
|
for (const [key, value] of Object.entries(core)) {
|
|
40
45
|
if (typeof value !== "function") continue;
|
|
41
46
|
Object.defineProperty(owned, key, { enumerable: true, value: (...args: unknown[]) => {
|
|
@@ -43,10 +48,14 @@ export async function createConfiguredTaskCore(options: ConfiguredTaskCoreOption
|
|
|
43
48
|
// Core methods may call sibling methods through `this` (notably intent ACK).
|
|
44
49
|
// Keep their original receiver while the outer promise owns the full operation.
|
|
45
50
|
const result: unknown = value.apply(core, args);
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
if (key === "recordWakeRequest") {
|
|
52
|
+
const request = result as WakeRequest;
|
|
53
|
+
return { flush: (signal?: AbortSignal): Promise<void> => {
|
|
54
|
+
if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task session is closed", { retryable: false });
|
|
55
|
+
return track(request.flush(signal));
|
|
56
|
+
} };
|
|
57
|
+
}
|
|
58
|
+
return result instanceof Promise ? track(result) : result;
|
|
50
59
|
} });
|
|
51
60
|
}
|
|
52
61
|
owned.close = () => {
|
package/src/extension.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Type } from "typebox";
|
|
|
5
5
|
|
|
6
6
|
import { abortableSleep } from "./abortable-sleep";
|
|
7
7
|
import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
|
|
8
|
-
import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
|
|
8
|
+
import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
|
|
9
9
|
import { createConfiguredTaskCore } from "./configured-task-core";
|
|
10
10
|
import type { OwnedTaskCore } from "./configured-task-core";
|
|
11
11
|
import type { SubmitIntentInput, SubmitIntentOutcome, TaskCore } from "./task-core";
|
|
@@ -142,8 +142,16 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
142
142
|
async recordInsertion(input, insertionSignal) {
|
|
143
143
|
if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal);
|
|
144
144
|
},
|
|
145
|
+
recordWakeRequest(input) {
|
|
146
|
+
if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
|
|
147
|
+
const request = activeCore.recordWakeRequest(input);
|
|
148
|
+
return { flush: async (flushSignal?: AbortSignal): Promise<void> => {
|
|
149
|
+
if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
|
|
150
|
+
await request.flush(flushSignal);
|
|
151
|
+
} };
|
|
152
|
+
},
|
|
145
153
|
};
|
|
146
|
-
await deliverTaskInbox({
|
|
154
|
+
const inboxOutboxError = await deliverTaskInbox({
|
|
147
155
|
sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
|
|
148
156
|
appendEntry(customType, data) { if (isCurrent()) pi.appendEntry(customType, data); },
|
|
149
157
|
}, guardedCore, {
|
|
@@ -151,7 +159,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
151
159
|
hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
|
|
152
160
|
sessionManager: context.sessionManager,
|
|
153
161
|
}, signal);
|
|
154
|
-
return outboxError;
|
|
162
|
+
return outboxError ?? inboxOutboxError;
|
|
155
163
|
});
|
|
156
164
|
const startBackgroundRefresh = (context: ExtensionContext, epoch: number): void => {
|
|
157
165
|
inboxContext = context;
|
|
@@ -502,7 +510,7 @@ function outboxFailureStatus(error: unknown): string {
|
|
|
502
510
|
if (error.code === "RELAY_PROFILE_REQUIRED") return "tasks: compatible memory-owned relay required";
|
|
503
511
|
if (error.code === "RELAY_AUTH_REQUIRED") return "tasks: Wolfpack authentication required";
|
|
504
512
|
}
|
|
505
|
-
return error instanceof TaskOutboxDeliveryError && error.code
|
|
513
|
+
return error instanceof TaskOutboxDeliveryError && isBlockedOutboxDeliveryCode(error.code) ? "tasks: outbox degraded" : "tasks: relay unavailable";
|
|
506
514
|
}
|
|
507
515
|
|
|
508
516
|
function isRelayLoss(error: unknown): error is TaskProtocolError {
|
package/src/task-core.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MAX_RELAY_PAYLOAD_BYTES,
|
|
3
|
-
INVALID_RELAY_METADATA,
|
|
4
3
|
ORIGIN_CANCELLATION_OPERATION,
|
|
5
4
|
PARENT_ACKNOWLEDGMENT_OPERATION,
|
|
6
5
|
TASK_PROTOCOL_VERSION,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
TaskEnvelopeKind,
|
|
11
10
|
TaskOutboxDeliveryError,
|
|
12
11
|
TaskProtocolError,
|
|
12
|
+
isBlockedOutboxDeliveryCode,
|
|
13
13
|
} from "./task-protocol";
|
|
14
14
|
import type {
|
|
15
15
|
RelayDelivery,
|
|
@@ -25,9 +25,9 @@ import type { TaskStore } from "./task-store";
|
|
|
25
25
|
|
|
26
26
|
const RECEIVE_PAGE_SIZE = 100;
|
|
27
27
|
const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
|
|
28
|
-
const TERMINAL_DELIVERY_CODES = new Set(["TARGET_NOT_REGISTERED", INVALID_RELAY_METADATA, "DELIVERY_UNCONFIRMED", "ENVELOPE_EXPIRED", "ENVELOPE_CONFLICT", "CROSS_RELAY_ENDPOINT"]);
|
|
29
28
|
const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
|
|
30
29
|
const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
|
|
30
|
+
const CONVERSATIONAL_EVENTS = new Set(["task.information", "task.question", "task.answer"]);
|
|
31
31
|
const CANONICAL_EVENTS = new Set([
|
|
32
32
|
"task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out",
|
|
33
33
|
"task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal",
|
|
@@ -61,6 +61,11 @@ export type SubmitIntentOutcome =
|
|
|
61
61
|
| { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
|
|
62
62
|
| { readonly authority: "receiver" };
|
|
63
63
|
|
|
64
|
+
/** Local wake-request evidence is persisted before Pi wake emission; relay delivery is flushed afterward. */
|
|
65
|
+
export interface WakeRequest {
|
|
66
|
+
flush(signal?: AbortSignal): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
64
69
|
export interface TaskCore {
|
|
65
70
|
readonly endpoint: TaskEndpoint;
|
|
66
71
|
connect(signal?: AbortSignal): Promise<void>;
|
|
@@ -73,6 +78,7 @@ export interface TaskCore {
|
|
|
73
78
|
submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
|
|
74
79
|
submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome>;
|
|
75
80
|
recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
|
|
81
|
+
recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }): WakeRequest;
|
|
76
82
|
evaluateTimeouts(signal?: AbortSignal): Promise<void>;
|
|
77
83
|
acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
|
|
78
84
|
}
|
|
@@ -214,6 +220,21 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
214
220
|
});
|
|
215
221
|
await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
|
|
216
222
|
},
|
|
223
|
+
recordWakeRequest(input) {
|
|
224
|
+
const task = requiredTask(options.store, input.taskId);
|
|
225
|
+
const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed };
|
|
226
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
227
|
+
options.store.transaction(() => {
|
|
228
|
+
persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
|
|
229
|
+
taskId: input.taskId,
|
|
230
|
+
type: "task.delivery_receipt",
|
|
231
|
+
payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
|
|
232
|
+
}, deliveryEvidenceOperation(evidence)).envelopeIds;
|
|
233
|
+
});
|
|
234
|
+
return { flush: async (signal?: AbortSignal): Promise<void> => {
|
|
235
|
+
await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
|
|
236
|
+
} };
|
|
237
|
+
},
|
|
217
238
|
async evaluateTimeouts(signal) {
|
|
218
239
|
const persistedEnvelopeIds: string[] = [];
|
|
219
240
|
for (const candidate of options.store.listTasks()) {
|
|
@@ -304,7 +325,9 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
|
|
|
304
325
|
if (!sameEndpoint(options.endpoint, task.origin) && !sameEndpoint(options.endpoint, task.target)) throw new TaskProtocolError("NOT_PARTICIPANT", "historical task belongs to a different endpoint", { retryable: false });
|
|
305
326
|
if (sameEndpoint(options.endpoint, task.origin)) {
|
|
306
327
|
const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
|
|
307
|
-
|
|
328
|
+
// The origin already recorded its own conversational event; echo only peer-authored conversations back for model incorporation.
|
|
329
|
+
const echoCanonicalEventToOrigin = !CONVERSATIONAL_EVENTS.has(input.type);
|
|
330
|
+
const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation, echoCanonicalEventToOrigin);
|
|
308
331
|
return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } } };
|
|
309
332
|
}
|
|
310
333
|
const envelopeId = ids();
|
|
@@ -364,21 +387,22 @@ interface Canonicalization {
|
|
|
364
387
|
readonly reused: boolean;
|
|
365
388
|
}
|
|
366
389
|
|
|
367
|
-
function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): Canonicalization {
|
|
390
|
+
function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string, echoCanonicalEventToOrigin = true): Canonicalization {
|
|
368
391
|
const terminal = TERMINAL_EVENTS.has(requestedType);
|
|
369
392
|
const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
|
|
370
393
|
const sequence = String(task.events.length + 1);
|
|
371
394
|
const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
|
|
372
|
-
const
|
|
373
|
-
const
|
|
374
|
-
const
|
|
395
|
+
const suppressSelfTargetConversationEcho = !echoCanonicalEventToOrigin && sameEndpoint(task.origin, task.target);
|
|
396
|
+
const targetEnvelope = suppressSelfTargetConversationEcho ? undefined : envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
|
|
397
|
+
const originEnvelope = !echoCanonicalEventToOrigin || sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
|
|
398
|
+
const persistedEnvelopeIds = [targetEnvelope, originEnvelope].flatMap((item) => item === undefined ? [] : [item.envelopeId]);
|
|
375
399
|
if (operation !== undefined) {
|
|
376
400
|
const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
|
|
377
401
|
if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
|
|
378
402
|
}
|
|
379
403
|
options.store.appendEvent(canonical);
|
|
380
404
|
if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
|
|
381
|
-
options.store.putOutbox(targetEnvelope);
|
|
405
|
+
if (targetEnvelope !== undefined) options.store.putOutbox(targetEnvelope);
|
|
382
406
|
if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
|
|
383
407
|
return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
|
|
384
408
|
}
|
|
@@ -401,7 +425,7 @@ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined,
|
|
|
401
425
|
options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
|
|
402
426
|
} catch (error) {
|
|
403
427
|
if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
|
|
404
|
-
if (error instanceof TaskProtocolError &&
|
|
428
|
+
if (error instanceof TaskProtocolError && isBlockedOutboxDeliveryCode(error.code) && !error.retryable) {
|
|
405
429
|
options.store.transaction(() => {
|
|
406
430
|
options.store.quarantineOutbox(record.envelope.envelopeId, {
|
|
407
431
|
errorCode: error.code,
|
package/src/task-inbox.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { TaskCore } from "./task-core";
|
|
2
|
-
import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
|
|
2
|
+
import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } 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
6
|
const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
|
|
7
7
|
const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
|
|
8
8
|
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"]);
|
|
9
10
|
|
|
10
11
|
interface InboxContext {
|
|
11
12
|
readonly isIdle: () => boolean;
|
|
@@ -33,9 +34,10 @@ type DeliveryEvidencePayload =
|
|
|
33
34
|
| { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
|
|
34
35
|
|
|
35
36
|
/** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
|
|
36
|
-
export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<
|
|
37
|
-
if (context.hasPendingMessages()) return;
|
|
37
|
+
export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<TaskOutboxDeliveryError | undefined> {
|
|
38
|
+
if (context.hasPendingMessages()) return undefined;
|
|
38
39
|
const deliveries = await core.receive(signal);
|
|
40
|
+
let outboxError: TaskOutboxDeliveryError | undefined;
|
|
39
41
|
for (const delivery of deliveries) {
|
|
40
42
|
if (delivery.envelope.kind === TaskEnvelopeKind.intent) {
|
|
41
43
|
await core.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
@@ -44,7 +46,7 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
44
46
|
}
|
|
45
47
|
const event = inboxEvent(delivery);
|
|
46
48
|
if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
|
|
47
|
-
if (context.hasPendingMessages()) return;
|
|
49
|
+
if (context.hasPendingMessages()) return outboxError;
|
|
48
50
|
const eventDetails = { taskId: event.taskId, eventId: event.eventId };
|
|
49
51
|
const eventKey = key(event.taskId, event.eventId);
|
|
50
52
|
if (!isModelVisible(event.type) && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
|
|
@@ -53,11 +55,12 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
53
55
|
pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
|
|
54
56
|
}
|
|
55
57
|
if (event.type === "task.created") {
|
|
56
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
58
|
+
const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
59
|
+
outboxError ??= evidenceError;
|
|
57
60
|
}
|
|
58
61
|
let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
59
62
|
if (!incorporated && isModelVisible(event.type)) {
|
|
60
|
-
if (!context.isIdle()) return;
|
|
63
|
+
if (!context.isIdle()) return outboxError;
|
|
61
64
|
pi.sendMessage({
|
|
62
65
|
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
63
66
|
content: renderTaskEvent(event),
|
|
@@ -66,16 +69,20 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
66
69
|
}, { triggerTurn: false });
|
|
67
70
|
incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
68
71
|
if (!incorporated) {
|
|
69
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
|
|
70
|
-
|
|
72
|
+
const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
|
|
73
|
+
outboxError ??= evidenceError;
|
|
74
|
+
return outboxError;
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
77
|
if (isModelVisible(event.type)) {
|
|
74
|
-
await core.recordInsertion(eventDetails, signal);
|
|
78
|
+
const insertionError = await settleDeliveryEvidence(() => core.recordInsertion(eventDetails, signal));
|
|
79
|
+
outboxError ??= insertionError;
|
|
75
80
|
let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
const wakeSuppressed = !wakeAccepted && hasAcknowledgedLocalOriginTerminal(core, event);
|
|
82
|
+
if (!wakeAccepted && !wakeSuppressed) {
|
|
83
|
+
if (!context.isIdle()) return outboxError;
|
|
84
|
+
// This synchronous local write closes the ACK/wake race; do not await relay I/O before Pi emits the wake.
|
|
85
|
+
const wakeRequest = core.recordWakeRequest(eventDetails);
|
|
79
86
|
pi.sendMessage({
|
|
80
87
|
customType: TASK_WAKE_CUSTOM_TYPE,
|
|
81
88
|
content: "Process the pending Pi task event.",
|
|
@@ -83,17 +90,33 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
83
90
|
details: eventDetails,
|
|
84
91
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
85
92
|
wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
|
|
86
|
-
if (!wakeAccepted) return;
|
|
93
|
+
if (!wakeAccepted) return outboxError;
|
|
94
|
+
const wakeRequestError = await settleDeliveryEvidence(() => wakeRequest.flush(signal));
|
|
95
|
+
outboxError ??= wakeRequestError;
|
|
96
|
+
}
|
|
97
|
+
if (wakeAccepted) {
|
|
98
|
+
const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
99
|
+
outboxError ??= wakeAcceptanceError;
|
|
87
100
|
}
|
|
88
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
89
101
|
}
|
|
90
102
|
await core.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
91
103
|
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
|
|
92
104
|
}
|
|
105
|
+
return outboxError;
|
|
93
106
|
}
|
|
94
107
|
|
|
95
|
-
async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<
|
|
96
|
-
|
|
108
|
+
async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<TaskOutboxDeliveryError | undefined> {
|
|
109
|
+
return settleDeliveryEvidence(() => core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function settleDeliveryEvidence(operation: () => Promise<void>): Promise<TaskOutboxDeliveryError | undefined> {
|
|
113
|
+
try {
|
|
114
|
+
await operation();
|
|
115
|
+
return undefined;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof TaskOutboxDeliveryError && !error.retryable && isBlockedOutboxDeliveryCode(error.code)) return error;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
97
120
|
}
|
|
98
121
|
|
|
99
122
|
function inboxEvent(delivery: RelayDelivery): TaskEvent {
|
|
@@ -114,6 +137,14 @@ function isModelVisible(type: string): boolean {
|
|
|
114
137
|
return !["task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
|
|
115
138
|
}
|
|
116
139
|
|
|
140
|
+
function hasAcknowledgedLocalOriginTerminal(core: TaskCore, event: TaskEvent): boolean {
|
|
141
|
+
if (!TERMINAL_EVENT_TYPES.has(event.type)) return false;
|
|
142
|
+
const task = core.getTask(event.taskId);
|
|
143
|
+
if (task === undefined || task.origin.relay !== core.endpoint.relay || task.origin.id !== core.endpoint.id) return false;
|
|
144
|
+
const terminalIndex = task.events.findIndex((candidate) => candidate.eventId === event.eventId && candidate.type === event.type);
|
|
145
|
+
return terminalIndex >= 0 && task.events.slice(terminalIndex + 1).some((candidate) => candidate.type === "task.parent_acknowledged");
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
function renderTaskEvent(event: TaskEvent): string {
|
|
118
149
|
if (event.type === "task.created") return `## task assignment\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`\n\n${String(event.payload.task ?? "")}`;
|
|
119
150
|
const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";
|
package/src/task-protocol.ts
CHANGED
|
@@ -5,6 +5,19 @@ 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
21
|
export const TaskDeliveryStage = {
|
|
9
22
|
receiverRecorded: "receiver_recorded",
|
|
10
23
|
piInsertion: "pi_insertion",
|
|
@@ -223,7 +223,12 @@ class Transport {
|
|
|
223
223
|
if (controller.signal.aborted) throw abortError();
|
|
224
224
|
const response = await this.fetcher(this.url, { method: "POST", headers: { "content-type": "application/json" }, body: encoded, redirect: "error", signal: controller.signal });
|
|
225
225
|
if (controller.signal.aborted) { void response.body?.cancel().catch(() => undefined); throw abortError(); }
|
|
226
|
-
|
|
226
|
+
const rateLimited = response.status === 429;
|
|
227
|
+
if (response.status === 401 || rateLimited) {
|
|
228
|
+
// Admission failures need no relay profile. Best-effort cleanup must not mask their status.
|
|
229
|
+
void response.body?.cancel().catch(() => undefined);
|
|
230
|
+
throw failure(rateLimited ? "RELAY_CAPACITY" : "RELAY_AUTH_REQUIRED", rateLimited);
|
|
231
|
+
}
|
|
227
232
|
const reader = response.body?.getReader();
|
|
228
233
|
if (!reader) throw failure("INVALID_RESPONSE", true);
|
|
229
234
|
const cancel = () => { void reader.cancel().catch(() => undefined); };
|