@sgtbeatdown/pi-tasks 0.1.9 → 0.1.11

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/extension.ts CHANGED
@@ -6,7 +6,8 @@ import { Type } from "typebox";
6
6
  import { abortableSleep } from "./abortable-sleep";
7
7
  import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
8
8
  import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
9
- import { createWolfpackTaskCore } from "./wolfpack-task-relay";
9
+ import { createConfiguredTaskCore } from "./configured-task-core";
10
+ import type { OwnedTaskCore } from "./configured-task-core";
10
11
  import type { SubmitIntentInput, SubmitIntentOutcome, TaskCore } from "./task-core";
11
12
  import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
12
13
 
@@ -15,6 +16,9 @@ const MIN_TASK_TIMEOUT_MS = 1_000;
15
16
  const MAX_TASK_TIMEOUT_MS = 86_400_000;
16
17
  const BACKGROUND_POLL_MS = 5_000;
17
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"]);
18
22
  const WAIT_POLL_MS = 250;
19
23
  const SUMMARY_MAX_CHARS = 1_200;
20
24
  const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
@@ -41,51 +45,91 @@ const DoneParams = Type.Object({
41
45
  artifacts: Type.Optional(Type.Array(Type.Object({ path: Type.String({ minLength: 1, description: "artifact paths are receiver-project-relative regular files" }), mimeType: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }))),
42
46
  });
43
47
 
44
- export type ConfiguredCoreFactory = (signal?: AbortSignal) => Promise<TaskCore>;
48
+ export type ConfiguredCoreFactory = (signal?: AbortSignal, rebind?: boolean) => Promise<TaskCore & Partial<Pick<OwnedTaskCore, "close">>>;
49
+ export interface ConfiguredCoreLoader extends ConfiguredCoreFactory {
50
+ close(): Promise<void>;
51
+ start(): Promise<void>;
52
+ }
53
+ const defaultCoreFactory: ConfiguredCoreFactory = (signal, rebind) => createConfiguredTaskCore({ rebind }, signal);
45
54
 
46
- /** Reuses a successful configured core while allowing the next lifecycle operation to retry a failed setup. */
47
- export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): ConfiguredCoreFactory {
48
- let corePromise: Promise<TaskCore> | undefined;
49
- return async (signal?: AbortSignal): Promise<TaskCore> => {
55
+ /** One lifecycle owns setup and its core. Late setup cannot resurrect a closed lifecycle. */
56
+ export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): ConfiguredCoreLoader {
57
+ let corePromise: ReturnType<ConfiguredCoreFactory> | undefined;
58
+ let controller = new AbortController();
59
+ let epoch = 0, closed = false;
60
+ let closing: Promise<void> = Promise.resolve();
61
+ const load: ConfiguredCoreLoader = async (signal, rebind) => {
62
+ if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle is closed", { retryable: false });
50
63
  if (corePromise) return corePromise;
51
- const candidate = Promise.resolve().then(() => factory(signal));
64
+ const current = epoch;
65
+ const lifetimeSignal = controller.signal;
66
+ const combined = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
67
+ const candidate = Promise.resolve().then(async () => {
68
+ if (combined.aborted) throw new TaskProtocolError("ABORTED", "task setup was cancelled");
69
+ const value = await factory(combined, rebind);
70
+ if (current !== epoch || closed || combined.aborted) {
71
+ await value.close?.();
72
+ throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
73
+ }
74
+ return value;
75
+ });
52
76
  corePromise = candidate;
53
- try {
54
- return await candidate;
55
- } catch (error) {
56
- if (corePromise === candidate) corePromise = undefined;
57
- throw error;
58
- }
77
+ try { return await candidate; }
78
+ catch (error) { if (corePromise === candidate) corePromise = undefined; throw error; }
79
+ };
80
+ load.start = async () => {
81
+ const current = epoch;
82
+ await closing;
83
+ if (current !== epoch) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was stopped during startup", { retryable: false });
84
+ if (closed) { closed = false; controller = new AbortController(); }
85
+ };
86
+ load.close = () => {
87
+ closed = true; epoch++; controller.abort();
88
+ const prior = corePromise; corePromise = undefined;
89
+ const dispose = async () => {
90
+ let value: Awaited<ReturnType<ConfiguredCoreFactory>> | undefined;
91
+ try { value = await prior; } catch { /* Setup failure already owns cleanup. */ }
92
+ await value?.close?.();
93
+ };
94
+ closing = Promise.all([closing, dispose()]).then(() => undefined);
95
+ return closing;
59
96
  };
97
+ return load;
60
98
  }
61
99
 
62
- /** Registers v2 endpoint-owned tools using a configured durable Wolfpack relay by default. */
63
- export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = (signal) => createWolfpackTaskCore({}, signal)): void {
100
+ /** Registers endpoint-owned tools using the memory-owned Wolfpack transport by default. */
101
+ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = defaultCoreFactory): void {
64
102
  let inboxContext: ExtensionContext | undefined;
65
103
  let backgroundTimer: ReturnType<typeof setInterval> | undefined;
66
104
  let lifecycleEpoch = 0;
67
105
  const closingTaskIds = new Set<string>();
68
106
  const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
69
- const configuredCore: ConfiguredCoreFactory = core === undefined ? createConfiguredCoreLoader(createCore) : async (): Promise<TaskCore> => core;
107
+ const ownedLoader = core === undefined ? createConfiguredCoreLoader(createCore) : undefined;
108
+ const configuredCore: ConfiguredCoreFactory = ownedLoader ?? (async (): Promise<TaskCore> => core!);
109
+ let relayLossRecovery: Promise<void> | undefined;
110
+ let pendingRelayLossCount: number | undefined;
70
111
  const refreshInbox = createSingleFlightInboxRefresh(async (signal) => {
71
112
  const context = inboxContext;
113
+ const epoch = lifecycleEpoch;
114
+ const isCurrent = (): boolean => inboxContext === context && lifecycleEpoch === epoch;
72
115
  if (!context) return undefined;
73
116
  const activeCore = await configuredCore(signal);
74
- if (inboxContext !== context) return undefined;
117
+ if (!isCurrent()) return undefined;
75
118
  let outboxError: unknown;
76
119
  try {
77
120
  await activeCore.flushOutbox(signal);
78
121
  } catch (error) {
122
+ if (isRelayLoss(error)) return error;
79
123
  outboxError = error;
80
124
  }
81
- if (inboxContext !== context) return undefined;
125
+ if (!isCurrent()) return undefined;
82
126
  try {
83
127
  await activeCore.evaluateTimeouts(signal);
84
128
  } catch (error) {
129
+ if (isRelayLoss(error)) return error;
85
130
  outboxError ??= error;
86
131
  }
87
- if (inboxContext !== context) return undefined;
88
- const isCurrent = (): boolean => inboxContext === context;
132
+ if (!isCurrent()) return undefined;
89
133
  const guardedCore: TaskCore = {
90
134
  ...activeCore,
91
135
  async receive(receiveSignal) {
@@ -109,10 +153,64 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
109
153
  }, signal);
110
154
  return outboxError;
111
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
+ };
112
209
  const refreshLifecycle = async (context: ExtensionContext, epoch: number): Promise<void> => {
113
210
  const isCurrent = (): boolean => lifecycleEpoch === epoch && inboxContext === context;
211
+ let activeCore: (TaskCore & Partial<Pick<OwnedTaskCore, "close">>) | undefined;
114
212
  try {
115
- const activeCore = await configuredCore();
213
+ activeCore = await configuredCore();
116
214
  let connectionError: unknown;
117
215
  try {
118
216
  await activeCore.connect();
@@ -120,12 +218,16 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
120
218
  connectionError = error;
121
219
  }
122
220
  if (!isCurrent()) return;
221
+ if (isRelayLoss(connectionError) && await recoverRelayLoss(context, epoch, activeCore)) return;
123
222
  const outboxError = await refreshInbox();
124
223
  if (!isCurrent()) return;
224
+ if (isRelayLoss(outboxError) && await recoverRelayLoss(context, epoch, activeCore)) return;
125
225
  const lifecycleError = outboxError ?? connectionError;
126
226
  const status = lifecycleError === undefined ? undefined : outboxFailureStatus(lifecycleError);
127
227
  context.ui.setStatus("pi-tasks", status === undefined ? undefined : context.ui.theme.fg("warning", status));
228
+ if (lifecycleError === undefined) announceFreshBinding(context);
128
229
  } catch (error) {
230
+ if (isCurrent() && isRelayLoss(error) && await recoverRelayLoss(context, epoch, activeCore)) return;
129
231
  if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
130
232
  }
131
233
  };
@@ -159,34 +261,35 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
159
261
  pi.on("session_start", async (_event, context) => {
160
262
  closingTaskIds.clear();
161
263
  const epoch = ++lifecycleEpoch;
162
- inboxContext = context;
163
- if (backgroundTimer) clearInterval(backgroundTimer);
164
- backgroundTimer = setInterval(() => {
165
- const currentContext = inboxContext;
166
- if (currentContext) void refreshLifecycle(currentContext, epoch);
167
- }, BACKGROUND_POLL_MS);
264
+ await ownedLoader?.start();
265
+ if (epoch !== lifecycleEpoch) return;
266
+ startBackgroundRefresh(context, epoch);
168
267
  await refreshLifecycle(context, epoch);
169
268
  });
170
269
  pi.on("agent_end", async (_event, context) => {
270
+ if (!inboxContext) return;
171
271
  const epoch = lifecycleEpoch;
172
272
  inboxContext = context;
173
273
  await refreshLifecycle(context, epoch);
174
274
  });
175
275
  pi.on("agent_settled", async (_event, context) => {
276
+ if (!inboxContext) return;
176
277
  const epoch = lifecycleEpoch;
177
278
  inboxContext = context;
178
279
  await refreshLifecycle(context, epoch);
179
280
  });
180
- pi.on("session_shutdown", () => {
281
+ pi.on("session_shutdown", async () => {
181
282
  closingTaskIds.clear();
182
283
  lifecycleEpoch += 1;
183
284
  if (backgroundTimer) clearInterval(backgroundTimer);
184
285
  backgroundTimer = undefined;
185
286
  inboxContext = undefined;
287
+ pendingRelayLossCount = undefined;
288
+ await ownedLoader?.close();
186
289
  });
187
290
 
188
291
  pi.registerTool({
189
- name: "agent_task_send", label: "Send Agent Task", description: "Persist an endpoint-owned task and submit its opaque assignment envelope to a relay.", parameters: SendParams,
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,
190
293
  async execute(_id, params, signal) {
191
294
  try {
192
295
  const sent = await (await configuredCore(signal)).createTask({ target: params.to, task: params.task, timeoutMs: params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS }, signal);
@@ -201,7 +304,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
201
304
  try {
202
305
  const activeCore = await configuredCore(signal);
203
306
  const task = activeCore.getTask(params.taskId);
204
- if (!task) return taskError(new Error("unknown local task"));
307
+ if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
205
308
  const deliveryEvidence = taskDeliveryEvidence(task);
206
309
  return toolResult({ ...task, deliveryEvidence }, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}${receiverAssignment(activeCore, task, context)}${deliveryEvidenceText(deliveryEvidence)}`);
207
310
  } catch (error) { return taskError(error); }
@@ -215,7 +318,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
215
318
  for (;;) {
216
319
  await refreshInbox(signal);
217
320
  const task = (await configuredCore(signal)).getTask(params.taskId);
218
- if (!task) return taskError(new Error("unknown local task"));
321
+ if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
219
322
  if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
220
323
  if (Date.now() >= deadline) return toolResult({ taskId: params.taskId, status: task.status }, `## task wait\n- task: \`${params.taskId}\`\n- status: ${task.status}\n- wait timed out`);
221
324
  onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
@@ -237,12 +340,12 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
237
340
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
238
341
  });
239
342
  pi.registerTool({
240
- name: "agent_task_message", label: "Message Agent Task", description: "Persist a message intent before relay submission.", parameters: MessageParams,
343
+ name: "agent_task_message", label: "Message Agent Task", description: "Record a message intent in this lifetime before relay submission.", parameters: MessageParams,
241
344
  async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.type}`, payload: { message: params.message } }, signal); return toolResult({ taskId: params.taskId }, `## task ${params.type}\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
242
345
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
243
346
  });
244
347
  pi.registerTool({
245
- name: "agent_task_cancel", label: "Cancel Agent Task", description: "Persist cancellation before relay submission.", parameters: TaskIdParams,
348
+ name: "agent_task_cancel", label: "Cancel Agent Task", description: "Record cancellation in this lifetime before relay submission.", parameters: TaskIdParams,
246
349
  async execute(_id, params, signal) {
247
350
  try {
248
351
  const activeCore = await configuredCore(signal);
@@ -262,7 +365,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
262
365
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
263
366
  });
264
367
  pi.registerTool({
265
- name: "agent_task_done", label: "Complete Agent Task", description: "Persist a terminal task intent before relay submission.", parameters: DoneParams,
368
+ name: "agent_task_done", label: "Complete Agent Task", description: "Record a terminal intent in this lifetime before relay submission.", parameters: DoneParams,
266
369
  async execute(_id, params, signal) {
267
370
  try {
268
371
  const activeCore = await configuredCore(signal);
@@ -275,7 +378,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
275
378
  return blockedDoneResult(activeCore, params.taskId, params.status, error) ?? taskError(error);
276
379
  }
277
380
  const task = activeCore.getTask(params.taskId);
278
- if (!task) return taskError(new Error("unknown local task"));
381
+ if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
279
382
  if (submission?.authority === "origin" && submission.canonicalEvent.type === "task.late_terminal") {
280
383
  return {
281
384
  ...toolResult({ taskId: params.taskId, requestedStatus: params.status, observedCanonicalStatus: task.status, canonicalEvent: submission.canonicalEvent }, `## canonical late terminal recorded\n- task: \`${params.taskId}\`\n- requested status: ${params.status}\n- canonical status remains: ${task.status}\n- ${params.summary}`),
@@ -313,34 +416,34 @@ export default function piTasks(pi: ExtensionAPI): void {
313
416
  }
314
417
 
315
418
  interface TaskDeliveryEvidence {
316
- readonly receiverPersistence: "not_confirmed" | "confirmed";
419
+ readonly receiverReceipt: "not_confirmed" | "confirmed";
317
420
  readonly piInsertion: "not_confirmed" | "blocked" | "confirmed";
318
421
  readonly wakeAcceptance: "not_confirmed" | "pending" | "confirmed";
319
422
  readonly modelExecution: "not_evidenced";
320
423
  }
321
424
 
322
425
  function taskDeliveryEvidence(task: TaskSnapshot): TaskDeliveryEvidence {
323
- let receiverPersistence: TaskDeliveryEvidence["receiverPersistence"] = "not_confirmed";
426
+ let receiverReceipt: TaskDeliveryEvidence["receiverReceipt"] = "not_confirmed";
324
427
  let piInsertion: TaskDeliveryEvidence["piInsertion"] = "not_confirmed";
325
428
  let wakeAcceptance: TaskDeliveryEvidence["wakeAcceptance"] = "not_confirmed";
326
429
  const assignmentEventId = task.events.find((event) => event.type === "task.created")?.eventId;
327
430
  for (const event of task.events) {
328
431
  if (event.type !== "task.delivery_receipt" || event.payload.eventId !== assignmentEventId) continue;
329
- if (event.payload.stage === TaskDeliveryStage.receiverPersisted) receiverPersistence = "confirmed";
432
+ if (event.payload.stage === TaskDeliveryStage.receiverRecorded) receiverReceipt = "confirmed";
330
433
  if (event.payload.stage === TaskDeliveryStage.piInsertion && event.payload.state === TaskDeliveryEvidenceState.blocked) piInsertion = "blocked";
331
434
  if (event.payload.stage === TaskDeliveryStage.piInserted || event.payload.stage === undefined) {
332
- receiverPersistence = "confirmed";
435
+ receiverReceipt = "confirmed";
333
436
  piInsertion = "confirmed";
334
437
  }
335
438
  if (event.payload.stage === TaskDeliveryStage.wakeRequested) wakeAcceptance = "pending";
336
439
  if (event.payload.stage === TaskDeliveryStage.wakeAccepted) wakeAcceptance = "confirmed";
337
440
  }
338
- return { receiverPersistence, piInsertion, wakeAcceptance, modelExecution: "not_evidenced" };
441
+ return { receiverReceipt, piInsertion, wakeAcceptance, modelExecution: "not_evidenced" };
339
442
  }
340
443
 
341
444
  function deliveryEvidenceText(evidence: TaskDeliveryEvidence): string {
342
445
  const insertion = evidence.piInsertion === "blocked" ? "blocked; retryable" : evidence.piInsertion;
343
- return `\n- receiver persistence: ${evidence.receiverPersistence}\n- Pi insertion: ${insertion}\n- wake acceptance: ${evidence.wakeAcceptance}\n- model execution: ${evidence.modelExecution}`;
446
+ return `\n- receiver receipt (RAM): ${evidence.receiverReceipt}\n- Pi insertion: ${insertion}\n- wake acceptance: ${evidence.wakeAcceptance}\n- model execution: ${evidence.modelExecution}`;
344
447
  }
345
448
 
346
449
  function toolResult(details: unknown, markdown: string): AgentToolResult<unknown> {
@@ -393,10 +496,19 @@ function blockedDeliveryWarning(error: TaskOutboxDeliveryError, target: TaskEndp
393
496
  return { code: error.code, message: error.message, retryable: false, delivery: "blocked", target, details };
394
497
  }
395
498
 
396
- function outboxFailureStatus(error: unknown): "tasks: relay unavailable" | "tasks: outbox degraded" {
499
+ function outboxFailureStatus(error: unknown): string {
500
+ if (error instanceof TaskProtocolError) {
501
+ if (RELAY_LOSS_CODES.has(error.code)) return "tasks: relay state lost; rebinding automatically";
502
+ if (error.code === "RELAY_PROFILE_REQUIRED") return "tasks: compatible memory-owned relay required";
503
+ if (error.code === "RELAY_AUTH_REQUIRED") return "tasks: Wolfpack authentication required";
504
+ }
397
505
  return error instanceof TaskOutboxDeliveryError && error.code === TARGET_NOT_REGISTERED_CODE ? "tasks: outbox degraded" : "tasks: relay unavailable";
398
506
  }
399
507
 
508
+ function isRelayLoss(error: unknown): error is TaskProtocolError {
509
+ return error instanceof TaskProtocolError && RELAY_LOSS_CODES.has(error.code);
510
+ }
511
+
400
512
  function terminal(status: string): boolean {
401
513
  return ["completed", "failed", "cancelled", "timed_out"].includes(status);
402
514
  }
package/src/index.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  export { createInMemoryTaskRelay, InMemoryTaskRelay } from "./in-memory-task-relay";
2
2
  export { createTaskCore } from "./task-core";
3
+ export { createConfiguredTaskCore } from "./configured-task-core";
4
+ export type { ConfiguredTaskCoreOptions, OwnedTaskCore } from "./configured-task-core";
3
5
  export { createTaskStore } from "./task-store";
4
- export {
5
- createWolfpackTaskCore,
6
- createWolfpackTaskRelay,
7
- WOLFPACK_TASK_RELAY_ID,
8
- WOLFPACK_TASK_RELAY_LEASE_MS,
9
- WOLFPACK_TASK_RELAY_PROTOCOL_VERSION,
10
- } from "./wolfpack-task-relay";
6
+ export { createVolatileTaskSession, VOLATILE_PROFILE } from "./volatile-task-session";
7
+ export type { VolatileTaskSession, VolatileTaskSessionOptions } from "./volatile-task-session";
8
+ export { WOLFPACK_TASK_RELAY_ID } from "./wolfpack-endpoint-view";
11
9
  export { runTaskRelayConformance } from "./task-relay-conformance";
12
10
  export {
13
11
  MAX_RELAY_PAYLOAD_BYTES,
@@ -25,6 +23,7 @@ export type {
25
23
  RelayInboxPage,
26
24
  RelayReceiveRequest,
27
25
  RelayTargetReference,
26
+ RelayTransportBinding,
28
27
  TaskEndpoint,
29
28
  TaskEvent,
30
29
  TaskIntent,
package/src/task-core.ts CHANGED
@@ -25,7 +25,7 @@ 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 TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
28
+ const TERMINAL_DELIVERY_CODES = new Set(["TARGET_NOT_REGISTERED", INVALID_RELAY_METADATA, "DELIVERY_UNCONFIRMED", "ENVELOPE_EXPIRED", "ENVELOPE_CONFLICT", "CROSS_RELAY_ENDPOINT"]);
29
29
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
30
30
  const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
31
31
  const CANONICAL_EVENTS = new Set([
@@ -301,6 +301,7 @@ interface PersistedIntent {
301
301
  }
302
302
 
303
303
  function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
304
+ 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 });
304
305
  if (sameEndpoint(options.endpoint, task.origin)) {
305
306
  const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
306
307
  const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
@@ -346,7 +347,7 @@ function deliveryEvidence(input: SubmitIntentInput): DeliveryEvidence | undefine
346
347
  if (stage === TaskDeliveryStage.piInsertion && state === TaskDeliveryEvidenceState.blocked && input.payload.retryable === true) {
347
348
  return { eventId: input.payload.eventId, stage, state, retryable: true };
348
349
  }
349
- if ((stage === TaskDeliveryStage.receiverPersisted || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
350
+ if ((stage === TaskDeliveryStage.receiverRecorded || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
350
351
  && state === TaskDeliveryEvidenceState.confirmed) {
351
352
  return { eventId: input.payload.eventId, stage, state };
352
353
  }
@@ -400,7 +401,7 @@ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined,
400
401
  options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
401
402
  } catch (error) {
402
403
  if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
403
- if (error instanceof TaskProtocolError && (error.code === TARGET_NOT_REGISTERED_CODE || error.code === INVALID_RELAY_METADATA) && !error.retryable) {
404
+ if (error instanceof TaskProtocolError && TERMINAL_DELIVERY_CODES.has(error.code) && !error.retryable) {
404
405
  options.store.transaction(() => {
405
406
  options.store.quarantineOutbox(record.envelope.envelopeId, {
406
407
  errorCode: error.code,
@@ -482,7 +483,7 @@ function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void
482
483
 
483
484
  function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
484
485
  const task = store.getTask(taskId);
485
- if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`);
486
+ if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`, { retryable: false });
486
487
  return task;
487
488
  }
488
489
 
package/src/task-inbox.ts CHANGED
@@ -5,6 +5,7 @@ import type { RelayDelivery, TaskEvent } from "./task-protocol";
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
+ const TASK_RECORD_CUSTOM_TYPE = "pi-tasks-event-record";
8
9
 
9
10
  interface InboxContext {
10
11
  readonly isIdle: () => boolean;
@@ -17,16 +18,18 @@ interface InboxPi {
17
18
  message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails },
18
19
  options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
19
20
  ): void;
20
- appendEntry(customType: string, data: { readonly cursor: string }): void;
21
+ appendEntry(customType: string, data: { readonly cursor: string } | TaskEventDetails): void;
21
22
  }
22
23
 
23
24
  export interface TaskEventDetails {
24
25
  readonly taskId: string;
25
26
  readonly eventId: string;
27
+ /** Historical evidence only; never used to rebuild active task state. */
28
+ readonly event?: TaskEvent;
26
29
  }
27
30
 
28
31
  type DeliveryEvidencePayload =
29
- | { readonly stage: typeof TaskDeliveryStage.receiverPersisted | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
32
+ | { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
30
33
  | { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
31
34
 
32
35
  /** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
@@ -44,8 +47,13 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
44
47
  if (context.hasPendingMessages()) return;
45
48
  const eventDetails = { taskId: event.taskId, eventId: event.eventId };
46
49
  const eventKey = key(event.taskId, event.eventId);
50
+ if (!isModelVisible(event.type) && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
51
+ // Archive receipts/parent ACK/late-terminal facts without waking the model.
52
+ // This is evidence only: no task state or delivery queue is reconstructed.
53
+ pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
54
+ }
47
55
  if (event.type === "task.created") {
48
- await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverPersisted, state: TaskDeliveryEvidenceState.confirmed }, signal);
56
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
49
57
  }
50
58
  let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
51
59
  if (!incorporated && isModelVisible(event.type)) {
@@ -54,7 +62,7 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
54
62
  customType: TASK_EVENT_CUSTOM_TYPE,
55
63
  content: renderTaskEvent(event),
56
64
  display: true,
57
- details: eventDetails,
65
+ details: { ...eventDetails, event },
58
66
  }, { triggerTurn: false });
59
67
  incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
60
68
  if (!incorporated) {
@@ -126,6 +134,12 @@ function taskMessageDetails(entries: readonly unknown[], customType: string): re
126
134
  return events;
127
135
  }
128
136
 
137
+ function recordedEventKeys(entries: readonly unknown[]): Set<string> {
138
+ return new Set(entries.flatMap(entry => isRecord(entry) && entry.type === "custom" && entry.customType === TASK_RECORD_CUSTOM_TYPE
139
+ && isRecord(entry.data) && typeof entry.data.taskId === "string" && typeof entry.data.eventId === "string"
140
+ ? [key(entry.data.taskId, entry.data.eventId)] : []));
141
+ }
142
+
129
143
  function taskMessageKeys(entries: readonly unknown[], customType: string): Set<string> {
130
144
  return new Set(taskMessageDetails(entries, customType).map((event) => key(event.taskId, event.eventId)));
131
145
  }
@@ -6,7 +6,7 @@ export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
6
6
  export const TERMINAL_INTENT_OPERATION = "terminal_intent";
7
7
 
8
8
  export const TaskDeliveryStage = {
9
- receiverPersisted: "receiver_persisted",
9
+ receiverRecorded: "receiver_recorded",
10
10
  piInsertion: "pi_insertion",
11
11
  piInserted: "pi_inserted",
12
12
  wakeRequested: "wake_requested",
@@ -23,6 +23,17 @@ export interface TaskEndpoint {
23
23
  readonly id: string;
24
24
  }
25
25
 
26
+ /** Endpoint-owned transport binding; not task lifecycle or relay replay state. */
27
+ export interface RelayTransportBinding {
28
+ readonly profile: "volatile-v1";
29
+ readonly epoch: string;
30
+ readonly endpoint: TaskEndpoint;
31
+ readonly generation: string;
32
+ readonly callerSession: string;
33
+ readonly url: string;
34
+ readonly reset?: true;
35
+ }
36
+
26
37
  /** A provider-defined reference which core resolves without interpreting. */
27
38
  export interface RelayTargetReference {
28
39
  readonly relay: string;