@sgtbeatdown/pi-tasks 0.1.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Memory-only endpoint-owned Pi agent delegation through Wolfpack relay v2.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -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 `agent_task_done` as their final action with the assigned task ID, terminal status, concise summary, and optional structured result, error, and artifact declarations. 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.
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 (!(result instanceof Promise)) return result;
47
- active.add(result);
48
- void result.then(() => active.delete(result), () => active.delete(result));
49
- return result;
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
@@ -142,6 +142,14 @@ 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
154
  const inboxOutboxError = await deliverTaskInbox({
147
155
  sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
package/src/task-core.ts CHANGED
@@ -27,6 +27,7 @@ const RECEIVE_PAGE_SIZE = 100;
27
27
  const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
28
28
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
29
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"]);
30
31
  const CANONICAL_EVENTS = new Set([
31
32
  "task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out",
32
33
  "task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal",
@@ -60,6 +61,11 @@ export type SubmitIntentOutcome =
60
61
  | { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
61
62
  | { readonly authority: "receiver" };
62
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
+
63
69
  export interface TaskCore {
64
70
  readonly endpoint: TaskEndpoint;
65
71
  connect(signal?: AbortSignal): Promise<void>;
@@ -72,6 +78,7 @@ export interface TaskCore {
72
78
  submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
73
79
  submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome>;
74
80
  recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
81
+ recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }): WakeRequest;
75
82
  evaluateTimeouts(signal?: AbortSignal): Promise<void>;
76
83
  acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
77
84
  }
@@ -213,6 +220,21 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
213
220
  });
214
221
  await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
215
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
+ },
216
238
  async evaluateTimeouts(signal) {
217
239
  const persistedEnvelopeIds: string[] = [];
218
240
  for (const candidate of options.store.listTasks()) {
@@ -303,7 +325,9 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
303
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 });
304
326
  if (sameEndpoint(options.endpoint, task.origin)) {
305
327
  const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
306
- const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
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);
307
331
  return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } } };
308
332
  }
309
333
  const envelopeId = ids();
@@ -363,21 +387,22 @@ interface Canonicalization {
363
387
  readonly reused: boolean;
364
388
  }
365
389
 
366
- 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 {
367
391
  const terminal = TERMINAL_EVENTS.has(requestedType);
368
392
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
369
393
  const sequence = String(task.events.length + 1);
370
394
  const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
371
- const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
372
- const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
373
- const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
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]);
374
399
  if (operation !== undefined) {
375
400
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
376
401
  if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
377
402
  }
378
403
  options.store.appendEvent(canonical);
379
404
  if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
380
- options.store.putOutbox(targetEnvelope);
405
+ if (targetEnvelope !== undefined) options.store.putOutbox(targetEnvelope);
381
406
  if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
382
407
  return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
383
408
  }
package/src/task-inbox.ts CHANGED
@@ -6,6 +6,7 @@ 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;
@@ -77,10 +78,11 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
77
78
  const insertionError = await settleDeliveryEvidence(() => core.recordInsertion(eventDetails, signal));
78
79
  outboxError ??= insertionError;
79
80
  let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
80
- if (!wakeAccepted) {
81
+ const wakeSuppressed = !wakeAccepted && hasAcknowledgedLocalOriginTerminal(core, event);
82
+ if (!wakeAccepted && !wakeSuppressed) {
81
83
  if (!context.isIdle()) return outboxError;
82
- const wakeRequestError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed }, signal);
83
- outboxError ??= wakeRequestError;
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);
84
86
  pi.sendMessage({
85
87
  customType: TASK_WAKE_CUSTOM_TYPE,
86
88
  content: "Process the pending Pi task event.",
@@ -89,9 +91,13 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
89
91
  }, { triggerTurn: true, deliverAs: "followUp" });
90
92
  wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
91
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;
92
100
  }
93
- const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
94
- outboxError ??= wakeAcceptanceError;
95
101
  }
96
102
  await core.acknowledgeRelayDelivery(delivery.cursor, signal);
97
103
  pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
@@ -131,6 +137,14 @@ function isModelVisible(type: string): boolean {
131
137
  return !["task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
132
138
  }
133
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
+
134
148
  function renderTaskEvent(event: TaskEvent): string {
135
149
  if (event.type === "task.created") return `## task assignment\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`\n\n${String(event.payload.task ?? "")}`;
136
150
  const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";