@sgtbeatdown/pi-tasks 0.1.10 → 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/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. Prior outcomes can be unknown; do not label them delivered, failed or recovered without evidence.
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
- A live endpoint detecting relay reset stops. `/task-relay-rebind` explains the loss boundary; only `/task-relay-rebind --accept-relay-loss` explicitly discards old state and binds a fresh scope. Automatic polling never invokes rebind. Session history remains untouched, but no historical task becomes active again.
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, explicit reset/rebind, 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Memory-only endpoint-owned Pi agent delegation through Wolfpack relay v2.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -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
- A live endpoint detecting relay reset requires explicit owner action. `/task-relay-rebind` explains the loss boundary; `/task-relay-rebind --accept-relay-loss` discards old RAM state and binds a fresh endpoint. Session history stays untouched, but historical tasks are not adopted. Never invoke rebind as an automatic retry, claim recovery or silently reassign possibly delivered work. Old database files are not read, migrated or deleted; no backward-compatible durable mode is supported.
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
- /** Explicit operator acceptance of loss/unknown outcomes; never set on automatic retry. */
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
@@ -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;
@@ -146,10 +153,64 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
146
153
  }, signal);
147
154
  return outboxError;
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
- const activeCore = await configuredCore();
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
- inboxContext = context;
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,13 +498,17 @@ function blockedDeliveryWarning(error: TaskOutboxDeliveryError, target: TaskEndp
467
498
 
468
499
  function outboxFailureStatus(error: unknown): string {
469
500
  if (error instanceof TaskProtocolError) {
470
- if (["RELAY_RESET", "RELAY_REBIND_REQUIRED"].includes(error.code)) return "tasks: relay reset; /task-relay-rebind";
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
505
  return error instanceof TaskOutboxDeliveryError && error.code === TARGET_NOT_REGISTERED_CODE ? "tasks: outbox degraded" : "tasks: relay unavailable";
475
506
  }
476
507
 
508
+ function isRelayLoss(error: unknown): error is TaskProtocolError {
509
+ return error instanceof TaskProtocolError && RELAY_LOSS_CODES.has(error.code);
510
+ }
511
+
477
512
  function terminal(status: string): boolean {
478
513
  return ["completed", "failed", "cancelled", "timed_out"].includes(status);
479
514
  }