@sgtbeatdown/pi-tasks 0.1.10 → 0.1.12
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 +3 -3
- package/package.json +1 -1
- package/skills/wolfpack-pi-task-delegation/SKILL.md +1 -1
- package/src/configured-task-core.ts +1 -1
- package/src/extension.ts +77 -42
- package/src/task-core.ts +2 -3
- package/src/task-inbox.ts +32 -15
- package/src/task-protocol.ts +13 -0
- package/src/volatile-task-session.ts +6 -1
package/README.md
CHANGED
|
@@ -30,13 +30,13 @@ The `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`.
|
|
|
30
30
|
- Active tasks, outbox intentions, individual ACK checkpoints, deduplication and delivery-blocked evidence are RAM-owned. Each endpoint has lower-only limits of16,384 records/32MiB encoded state. Admission fails before exceeding those bounds; they are not a process-RSS guarantee.
|
|
31
31
|
- `agent_task_send` confirms relay acceptance, **not execution**. `receiver_recorded` confirms receiver RAM receipt, **not durable persistence**. Pi insertion and wake acceptance are separate evidence, never proof of model execution.
|
|
32
32
|
- Session history is not an operational journal. A new process/lifecycle starts empty with a fresh endpoint/generation; it does not reconstruct tasks, replay envelopes, reuse old ACKs or reopen a worker gate from old history.
|
|
33
|
-
- A relay restart can lose even accepted mail. A Pi restart loses that endpoint's active task state.
|
|
33
|
+
- A relay restart can lose even accepted mail. A Pi restart loses that endpoint's active task state. Relay-loss evidence marks affected work failed only in the discarded local lifetime; its remote outcome remains explicitly unknown and is never presented as canonical delivery or completion evidence.
|
|
34
34
|
|
|
35
35
|
## memory-owned lifecycle
|
|
36
36
|
|
|
37
37
|
`createConfiguredTaskCore()` owns its transport and `createTaskStore()` RAM state. Startup registers; polling runs every five seconds and at agent settlement. Shutdown fences new work, aborts requests, drains active calls and discards state. Late callbacks cannot mutate a successor lifetime.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
The default extension automatically replaces a lifetime only after a structured `RELAY_RESET` or `RELAY_REBIND_REQUIRED`. It fences and discards the unusable RAM state, records each active task in session history as locally failed with `RELAY_STATE_LOST` and an explicitly unknown remote outcome, then binds a fresh empty endpoint. The loss record does not wake the model by itself. Transient availability, authentication, capacity, abort, profile and malformed-response failures never trigger replacement. Historical tasks are not adopted into the successor lifetime.
|
|
40
40
|
|
|
41
41
|
`createVolatileTaskSession({ url, callerSession, store })` is the lower-level API for a caller-owned RAM store. Transport reconnect within that same lifetime retains pending immutable retries; `rebind()` clears the store, while `close()` stops transport and the caller then closes its state. Persistence/path options are rejected. Old database files are neither read nor deleted.
|
|
42
42
|
|
|
@@ -98,4 +98,4 @@ PI_TASKS_WOLFPACK_REVISION=<full-commit-id> \
|
|
|
98
98
|
bun test tests/volatile-real-worker.test.ts tests/volatile-extension-worker.test.ts
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
Wolfpack worker tests require its supported Bun runtime (currently stable>=1.4.2). Optional source-selected checks use private HTTP/workers, not installed services. They test same-lifetime retry/ACK,
|
|
101
|
+
Wolfpack worker tests require its supported Bun runtime (currently stable>=1.4.2). Optional source-selected checks use private HTTP/workers, not installed services. They test same-lifetime retry/ACK, automatic default-extension reset recovery, explicit lower-level session replacement, fresh endpoint state and history-only evidence. `createInMemoryTaskRelay` is a deterministic conformance fixture, never the default runtime transport. Physical two-machine qualification, final-path performance, release packaging and live activation remain separate gates.
|
package/package.json
CHANGED
|
@@ -82,7 +82,7 @@ Assignment completion ends the assigned task, not the reusable role session. Whe
|
|
|
82
82
|
|
|
83
83
|
Wolfpack is a memory-only, content-blind relay; each Pi endpoint keeps active task state, event order, retries and receipts in RAM. There is no SQLite task database. Relay acceptance is not execution; receiver receipt is not durable persistence. Model-visible `pi-tasks-event` entries include the complete structured event in session history; task wakes use Pi's safe `deliverAs: "followUp"` queue. Non-waking receipts, parent ACKs and late-terminal facts are archived as `pi-tasks-event-record` custom entries, not replayed. History is for inspection, never reconstruction/replay or worker authorization after restart. A relay restart can lose even accepted mail; a Pi restart loses its active task state and starts a fresh endpoint.
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
The default extension automatically replaces its endpoint lifetime only after structured `RELAY_RESET` or `RELAY_REBIND_REQUIRED` evidence. It fences and discards old RAM state, records every active old-lifetime task in Pi session history as locally failed with `RELAY_STATE_LOST` and an explicitly unknown remote outcome, then binds a fresh empty endpoint. This local loss record is not a canonical remote terminal event and does not wake the model by itself. Historical tasks are never adopted or silently reassigned. Transient availability, authentication, capacity, abort, profile and malformed-response failures do not rotate the lifetime. Old database files are not read, migrated or deleted; no backward-compatible durable mode is supported.
|
|
86
86
|
|
|
87
87
|
Receiver terminal submission has one task-wide logical identity. Same-status retries reuse it; a conflicting terminal status fails closed. Canonical status remains origin-owned, while `terminalDelivery` separately exposes `not_submitted`, `pending`, `accepted`, or `delivery_blocked`. Parent acknowledgment is terminal-only and retries reuse one logical event and stable envelope identities.
|
|
88
88
|
|
|
@@ -13,7 +13,7 @@ export interface ConfiguredTaskCoreOptions {
|
|
|
13
13
|
readonly baseUrl?: string;
|
|
14
14
|
readonly fetch?: typeof fetch;
|
|
15
15
|
readonly requestTimeoutMs?: number;
|
|
16
|
-
/**
|
|
16
|
+
/** Fresh-lifetime registration after accepted state loss; the extension uses it only for structured relay-loss evidence. */
|
|
17
17
|
readonly rebind?: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
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";
|
|
@@ -16,6 +16,9 @@ const MIN_TASK_TIMEOUT_MS = 1_000;
|
|
|
16
16
|
const MAX_TASK_TIMEOUT_MS = 86_400_000;
|
|
17
17
|
const BACKGROUND_POLL_MS = 5_000;
|
|
18
18
|
const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
|
|
19
|
+
const RELAY_STATE_LOST_CODE = "RELAY_STATE_LOST";
|
|
20
|
+
const RELAY_LOSS_CUSTOM_TYPE = "pi-tasks-relay-loss";
|
|
21
|
+
const RELAY_LOSS_CODES = new Set(["RELAY_RESET", "RELAY_REBIND_REQUIRED"]);
|
|
19
22
|
const WAIT_POLL_MS = 250;
|
|
20
23
|
const SUMMARY_MAX_CHARS = 1_200;
|
|
21
24
|
const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
|
|
@@ -103,6 +106,8 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
103
106
|
const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
|
|
104
107
|
const ownedLoader = core === undefined ? createConfiguredCoreLoader(createCore) : undefined;
|
|
105
108
|
const configuredCore: ConfiguredCoreFactory = ownedLoader ?? (async (): Promise<TaskCore> => core!);
|
|
109
|
+
let relayLossRecovery: Promise<void> | undefined;
|
|
110
|
+
let pendingRelayLossCount: number | undefined;
|
|
106
111
|
const refreshInbox = createSingleFlightInboxRefresh(async (signal) => {
|
|
107
112
|
const context = inboxContext;
|
|
108
113
|
const epoch = lifecycleEpoch;
|
|
@@ -114,12 +119,14 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
114
119
|
try {
|
|
115
120
|
await activeCore.flushOutbox(signal);
|
|
116
121
|
} catch (error) {
|
|
122
|
+
if (isRelayLoss(error)) return error;
|
|
117
123
|
outboxError = error;
|
|
118
124
|
}
|
|
119
125
|
if (!isCurrent()) return undefined;
|
|
120
126
|
try {
|
|
121
127
|
await activeCore.evaluateTimeouts(signal);
|
|
122
128
|
} catch (error) {
|
|
129
|
+
if (isRelayLoss(error)) return error;
|
|
123
130
|
outboxError ??= error;
|
|
124
131
|
}
|
|
125
132
|
if (!isCurrent()) return undefined;
|
|
@@ -136,7 +143,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
136
143
|
if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal);
|
|
137
144
|
},
|
|
138
145
|
};
|
|
139
|
-
await deliverTaskInbox({
|
|
146
|
+
const inboxOutboxError = await deliverTaskInbox({
|
|
140
147
|
sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
|
|
141
148
|
appendEntry(customType, data) { if (isCurrent()) pi.appendEntry(customType, data); },
|
|
142
149
|
}, guardedCore, {
|
|
@@ -144,12 +151,66 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
144
151
|
hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
|
|
145
152
|
sessionManager: context.sessionManager,
|
|
146
153
|
}, signal);
|
|
147
|
-
return outboxError;
|
|
154
|
+
return outboxError ?? inboxOutboxError;
|
|
148
155
|
});
|
|
156
|
+
const startBackgroundRefresh = (context: ExtensionContext, epoch: number): void => {
|
|
157
|
+
inboxContext = context;
|
|
158
|
+
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
159
|
+
backgroundTimer = setInterval(() => {
|
|
160
|
+
const currentContext = inboxContext;
|
|
161
|
+
if (currentContext) void refreshLifecycle(currentContext, epoch);
|
|
162
|
+
}, BACKGROUND_POLL_MS);
|
|
163
|
+
};
|
|
164
|
+
const announceFreshBinding = (context: ExtensionContext): void => {
|
|
165
|
+
if (pendingRelayLossCount === undefined) return;
|
|
166
|
+
const count = pendingRelayLossCount;
|
|
167
|
+
pendingRelayLossCount = undefined;
|
|
168
|
+
const taskSummary = count === 0 ? "No active tasks required local failure records." : `${count} active ${count === 1 ? "task was" : "tasks were"} marked locally failed with unknown remote outcome.`;
|
|
169
|
+
context.ui.notify(`Relay state was lost; automatically bound a fresh endpoint. ${taskSummary}`, "warning");
|
|
170
|
+
};
|
|
171
|
+
const recoverRelayLoss = async (context: ExtensionContext, epoch: number, activeCore: TaskCore | undefined): Promise<boolean> => {
|
|
172
|
+
if (!ownedLoader || lifecycleEpoch !== epoch || inboxContext !== context) return false;
|
|
173
|
+
if (relayLossRecovery) { await relayLossRecovery; return true; }
|
|
174
|
+
const pending = (async (): Promise<void> => {
|
|
175
|
+
const recoveryEpoch = ++lifecycleEpoch;
|
|
176
|
+
const activeTasks = activeCore?.listTasks().filter((task) => task.status === "active") ?? [];
|
|
177
|
+
inboxContext = undefined;
|
|
178
|
+
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
179
|
+
backgroundTimer = undefined;
|
|
180
|
+
await ownedLoader.close();
|
|
181
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
182
|
+
for (const task of activeTasks) {
|
|
183
|
+
pi.sendMessage({
|
|
184
|
+
customType: RELAY_LOSS_CUSTOM_TYPE,
|
|
185
|
+
content: `## task failed locally\ntask: \`${task.taskId}\`\n\nRelay state was lost; the remote outcome is unknown.`,
|
|
186
|
+
display: true,
|
|
187
|
+
details: { taskId: task.taskId, status: "failed", error: { code: RELAY_STATE_LOST_CODE, message: "relay state was lost; remote outcome is unknown", retryable: false }, remoteOutcome: "unknown" },
|
|
188
|
+
}, { triggerTurn: false });
|
|
189
|
+
}
|
|
190
|
+
pendingRelayLossCount = (pendingRelayLossCount ?? 0) + activeTasks.length;
|
|
191
|
+
closingTaskIds.clear();
|
|
192
|
+
try {
|
|
193
|
+
await ownedLoader.start();
|
|
194
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
195
|
+
await ownedLoader(undefined, true);
|
|
196
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
197
|
+
announceFreshBinding(context);
|
|
198
|
+
context.ui.setStatus("pi-tasks", undefined);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
201
|
+
context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
|
|
202
|
+
}
|
|
203
|
+
if (recoveryEpoch === lifecycleEpoch) startBackgroundRefresh(context, recoveryEpoch);
|
|
204
|
+
})();
|
|
205
|
+
relayLossRecovery = pending;
|
|
206
|
+
try { await pending; } finally { if (relayLossRecovery === pending) relayLossRecovery = undefined; }
|
|
207
|
+
return true;
|
|
208
|
+
};
|
|
149
209
|
const refreshLifecycle = async (context: ExtensionContext, epoch: number): Promise<void> => {
|
|
150
210
|
const isCurrent = (): boolean => lifecycleEpoch === epoch && inboxContext === context;
|
|
211
|
+
let activeCore: (TaskCore & Partial<Pick<OwnedTaskCore, "close">>) | undefined;
|
|
151
212
|
try {
|
|
152
|
-
|
|
213
|
+
activeCore = await configuredCore();
|
|
153
214
|
let connectionError: unknown;
|
|
154
215
|
try {
|
|
155
216
|
await activeCore.connect();
|
|
@@ -157,12 +218,16 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
157
218
|
connectionError = error;
|
|
158
219
|
}
|
|
159
220
|
if (!isCurrent()) return;
|
|
221
|
+
if (isRelayLoss(connectionError) && await recoverRelayLoss(context, epoch, activeCore)) return;
|
|
160
222
|
const outboxError = await refreshInbox();
|
|
161
223
|
if (!isCurrent()) return;
|
|
224
|
+
if (isRelayLoss(outboxError) && await recoverRelayLoss(context, epoch, activeCore)) return;
|
|
162
225
|
const lifecycleError = outboxError ?? connectionError;
|
|
163
226
|
const status = lifecycleError === undefined ? undefined : outboxFailureStatus(lifecycleError);
|
|
164
227
|
context.ui.setStatus("pi-tasks", status === undefined ? undefined : context.ui.theme.fg("warning", status));
|
|
228
|
+
if (lifecycleError === undefined) announceFreshBinding(context);
|
|
165
229
|
} catch (error) {
|
|
230
|
+
if (isCurrent() && isRelayLoss(error) && await recoverRelayLoss(context, epoch, activeCore)) return;
|
|
166
231
|
if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
|
|
167
232
|
}
|
|
168
233
|
};
|
|
@@ -198,12 +263,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
198
263
|
const epoch = ++lifecycleEpoch;
|
|
199
264
|
await ownedLoader?.start();
|
|
200
265
|
if (epoch !== lifecycleEpoch) return;
|
|
201
|
-
|
|
202
|
-
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
203
|
-
backgroundTimer = setInterval(() => {
|
|
204
|
-
const currentContext = inboxContext;
|
|
205
|
-
if (currentContext) void refreshLifecycle(currentContext, epoch);
|
|
206
|
-
}, BACKGROUND_POLL_MS);
|
|
266
|
+
startBackgroundRefresh(context, epoch);
|
|
207
267
|
await refreshLifecycle(context, epoch);
|
|
208
268
|
});
|
|
209
269
|
pi.on("agent_end", async (_event, context) => {
|
|
@@ -224,39 +284,10 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
|
|
|
224
284
|
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
225
285
|
backgroundTimer = undefined;
|
|
226
286
|
inboxContext = undefined;
|
|
287
|
+
pendingRelayLossCount = undefined;
|
|
227
288
|
await ownedLoader?.close();
|
|
228
289
|
});
|
|
229
290
|
|
|
230
|
-
if (ownedLoader && createCore === defaultCoreFactory) pi.registerCommand("task-relay-rebind", {
|
|
231
|
-
description: "Accept relay loss, discard active task state and bind a fresh endpoint",
|
|
232
|
-
async handler(args, context) {
|
|
233
|
-
if (args.trim() !== "--accept-relay-loss") {
|
|
234
|
-
context.ui.notify("Relay restart can lose accepted mail. Rebind discards this lifetime's task state; Pi session history remains, but is never replayed. Run /task-relay-rebind --accept-relay-loss to continue with a fresh endpoint.", "warning");
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
const epoch = ++lifecycleEpoch;
|
|
238
|
-
inboxContext = undefined;
|
|
239
|
-
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
240
|
-
backgroundTimer = undefined;
|
|
241
|
-
await ownedLoader.close();
|
|
242
|
-
if (epoch !== lifecycleEpoch) return;
|
|
243
|
-
try {
|
|
244
|
-
await ownedLoader.start();
|
|
245
|
-
if (epoch !== lifecycleEpoch) return;
|
|
246
|
-
await ownedLoader(undefined, true);
|
|
247
|
-
if (epoch !== lifecycleEpoch) return;
|
|
248
|
-
closingTaskIds.clear();
|
|
249
|
-
context.ui.notify("Fresh task endpoint bound with empty state. Prior tasks remain only in session history.", "info");
|
|
250
|
-
} catch (error) {
|
|
251
|
-
if (epoch === lifecycleEpoch) context.ui.notify(outboxFailureStatus(error), "error");
|
|
252
|
-
}
|
|
253
|
-
if (epoch !== lifecycleEpoch) return;
|
|
254
|
-
inboxContext = context;
|
|
255
|
-
backgroundTimer = setInterval(() => { if (inboxContext) void refreshLifecycle(inboxContext, epoch); }, BACKGROUND_POLL_MS);
|
|
256
|
-
await refreshLifecycle(context, epoch);
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
|
|
260
291
|
pi.registerTool({
|
|
261
292
|
name: "agent_task_send", label: "Send Agent Task", description: "Create a task in this endpoint's RAM and submit its assignment; restart loses active state.", parameters: SendParams,
|
|
262
293
|
async execute(_id, params, signal) {
|
|
@@ -467,11 +498,15 @@ function blockedDeliveryWarning(error: TaskOutboxDeliveryError, target: TaskEndp
|
|
|
467
498
|
|
|
468
499
|
function outboxFailureStatus(error: unknown): string {
|
|
469
500
|
if (error instanceof TaskProtocolError) {
|
|
470
|
-
if (
|
|
501
|
+
if (RELAY_LOSS_CODES.has(error.code)) return "tasks: relay state lost; rebinding automatically";
|
|
471
502
|
if (error.code === "RELAY_PROFILE_REQUIRED") return "tasks: compatible memory-owned relay required";
|
|
472
503
|
if (error.code === "RELAY_AUTH_REQUIRED") return "tasks: Wolfpack authentication required";
|
|
473
504
|
}
|
|
474
|
-
return error instanceof TaskOutboxDeliveryError && error.code
|
|
505
|
+
return error instanceof TaskOutboxDeliveryError && isBlockedOutboxDeliveryCode(error.code) ? "tasks: outbox degraded" : "tasks: relay unavailable";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isRelayLoss(error: unknown): error is TaskProtocolError {
|
|
509
|
+
return error instanceof TaskProtocolError && RELAY_LOSS_CODES.has(error.code);
|
|
475
510
|
}
|
|
476
511
|
|
|
477
512
|
function terminal(status: string): boolean {
|
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,7 +25,6 @@ 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"]);
|
|
31
30
|
const CANONICAL_EVENTS = new Set([
|
|
@@ -401,7 +400,7 @@ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined,
|
|
|
401
400
|
options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
|
|
402
401
|
} catch (error) {
|
|
403
402
|
if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
|
|
404
|
-
if (error instanceof TaskProtocolError &&
|
|
403
|
+
if (error instanceof TaskProtocolError && isBlockedOutboxDeliveryCode(error.code) && !error.retryable) {
|
|
405
404
|
options.store.transaction(() => {
|
|
406
405
|
options.store.quarantineOutbox(record.envelope.envelopeId, {
|
|
407
406
|
errorCode: error.code,
|
package/src/task-inbox.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
@@ -33,9 +33,10 @@ type DeliveryEvidencePayload =
|
|
|
33
33
|
| { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
|
|
34
34
|
|
|
35
35
|
/** 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;
|
|
36
|
+
export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<TaskOutboxDeliveryError | undefined> {
|
|
37
|
+
if (context.hasPendingMessages()) return undefined;
|
|
38
38
|
const deliveries = await core.receive(signal);
|
|
39
|
+
let outboxError: TaskOutboxDeliveryError | undefined;
|
|
39
40
|
for (const delivery of deliveries) {
|
|
40
41
|
if (delivery.envelope.kind === TaskEnvelopeKind.intent) {
|
|
41
42
|
await core.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
@@ -44,7 +45,7 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
44
45
|
}
|
|
45
46
|
const event = inboxEvent(delivery);
|
|
46
47
|
if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
|
|
47
|
-
if (context.hasPendingMessages()) return;
|
|
48
|
+
if (context.hasPendingMessages()) return outboxError;
|
|
48
49
|
const eventDetails = { taskId: event.taskId, eventId: event.eventId };
|
|
49
50
|
const eventKey = key(event.taskId, event.eventId);
|
|
50
51
|
if (!isModelVisible(event.type) && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
|
|
@@ -53,11 +54,12 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
53
54
|
pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
|
|
54
55
|
}
|
|
55
56
|
if (event.type === "task.created") {
|
|
56
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
57
|
+
const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
58
|
+
outboxError ??= evidenceError;
|
|
57
59
|
}
|
|
58
60
|
let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
59
61
|
if (!incorporated && isModelVisible(event.type)) {
|
|
60
|
-
if (!context.isIdle()) return;
|
|
62
|
+
if (!context.isIdle()) return outboxError;
|
|
61
63
|
pi.sendMessage({
|
|
62
64
|
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
63
65
|
content: renderTaskEvent(event),
|
|
@@ -66,16 +68,19 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
66
68
|
}, { triggerTurn: false });
|
|
67
69
|
incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
68
70
|
if (!incorporated) {
|
|
69
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
|
|
70
|
-
|
|
71
|
+
const evidenceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
|
|
72
|
+
outboxError ??= evidenceError;
|
|
73
|
+
return outboxError;
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
if (isModelVisible(event.type)) {
|
|
74
|
-
await core.recordInsertion(eventDetails, signal);
|
|
77
|
+
const insertionError = await settleDeliveryEvidence(() => core.recordInsertion(eventDetails, signal));
|
|
78
|
+
outboxError ??= insertionError;
|
|
75
79
|
let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
|
|
76
80
|
if (!wakeAccepted) {
|
|
77
|
-
if (!context.isIdle()) return;
|
|
78
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
81
|
+
if (!context.isIdle()) return outboxError;
|
|
82
|
+
const wakeRequestError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
83
|
+
outboxError ??= wakeRequestError;
|
|
79
84
|
pi.sendMessage({
|
|
80
85
|
customType: TASK_WAKE_CUSTOM_TYPE,
|
|
81
86
|
content: "Process the pending Pi task event.",
|
|
@@ -83,17 +88,29 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
83
88
|
details: eventDetails,
|
|
84
89
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
85
90
|
wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
|
|
86
|
-
if (!wakeAccepted) return;
|
|
91
|
+
if (!wakeAccepted) return outboxError;
|
|
87
92
|
}
|
|
88
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
93
|
+
const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
94
|
+
outboxError ??= wakeAcceptanceError;
|
|
89
95
|
}
|
|
90
96
|
await core.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
91
97
|
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
|
|
92
98
|
}
|
|
99
|
+
return outboxError;
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<
|
|
96
|
-
|
|
102
|
+
async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<TaskOutboxDeliveryError | undefined> {
|
|
103
|
+
return settleDeliveryEvidence(() => core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function settleDeliveryEvidence(operation: () => Promise<void>): Promise<TaskOutboxDeliveryError | undefined> {
|
|
107
|
+
try {
|
|
108
|
+
await operation();
|
|
109
|
+
return undefined;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof TaskOutboxDeliveryError && !error.retryable && isBlockedOutboxDeliveryCode(error.code)) return error;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
function inboxEvent(delivery: RelayDelivery): TaskEvent {
|
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); };
|