@sgtbeatdown/pi-tasks 0.1.15 → 0.1.16

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
@@ -140,16 +140,18 @@ try {
140
140
  });
141
141
 
142
142
  // a real application would consume the assignment and do the work here.
143
- for (const delivery of await worker.receive()) {
144
- await worker.acknowledgeRelayDelivery(delivery.cursor);
143
+ for (const item of await worker.receive()) {
144
+ if (item.kind === "relay") await worker.acknowledgeRelayDelivery(item.delivery.cursor);
145
145
  }
146
146
  await worker.submitIntent({
147
147
  taskId,
148
148
  type: "task.completed",
149
149
  payload: { summary: "the change adds task coordination" },
150
150
  });
151
- for (const delivery of await parent.receive()) {
152
- await parent.acknowledgeRelayDelivery(delivery.cursor);
151
+ // relay items carry peer mail; local items are the parent's own view of events it canonicalized.
152
+ for (const item of await parent.receive()) {
153
+ if (item.kind === "relay") await parent.acknowledgeRelayDelivery(item.delivery.cursor);
154
+ else parent.completeLocalPresentation(item.event.eventId);
153
155
  }
154
156
 
155
157
  console.log(parent.getTask(taskId)?.status); // completed
@@ -166,6 +168,10 @@ this demonstrates task coordination, not agent execution. both endpoints share o
166
168
 
167
169
  implement the exported [`TaskRelay` interface](src/task-protocol.ts) to connect endpoints, resolve targets, send envelopes, receive mail, and acknowledge individual deliveries. provide a separate `createTaskStore()` and `createTaskCore()` for each endpoint. `runTaskRelayConformance` is available to check a relay implementation.
168
170
 
171
+ `receive()` returns items in order: `relay` items wrap a delivery you acknowledge with `acknowledgeRelayDelivery`, and `local` items carry an event the origin itself canonicalized, which you complete with `completeLocalPresentation` once presented.
172
+
173
+ `createTaskCore({ timers })` accepts a `TaskTimers` clock for the delivery budget (deadline, retry slices, cooldowns), which lets tests run those bounds on virtual time; task records still use `clock`. an origin can move an active task off a target whose loss the relay reported with `reassignTask`; the [reference](docs/protocol-reference.md#memory-owned-lifecycle) covers its preconditions and why the default extension does not call it yet.
174
+
169
175
  your application owns polling, retries, timeout evaluation, delivery acknowledgments, and shutdown. it also owns how assignments reach an agent and how that agent runs them. the core alone does not install pi tools or wake a model.
170
176
 
171
177
  ## important limits
@@ -8,7 +8,7 @@ The [relay control API](https://github.com/almogdepaz/wolfpack/blob/main/docs/co
8
8
 
9
9
  ## endpoint-owned relay
10
10
 
11
- The default extension requires a compatible Wolfpack memory-owned server. It negotiates `volatile-v1` at `POST /api/task-relay/volatile-v1`. No transport opt-in flag is needed. The old durable relay adapter is removed, not retained as a compatibility mode.
11
+ The default extension requires a compatible Wolfpack memory-owned server. It negotiates `volatile-v1` at `POST /api/task-relay/volatile-v1`. No transport opt-in flag is needed. The old durable relay adapter is removed, not retained as a compatibility mode. The transport keeps at most 8 requests in flight, with 2 more slots reserved for ACK/connect; further requests wait for a slot in FIFO order instead of failing locally. A waiter aborted by its caller has sent nothing and fails with a retryable `ABORTED`; `RELAY_CAPACITY` comes only from the server.
12
12
 
13
13
  Set `WOLFPACK_SESSION_NAME` for each Pi process. `WOLFPACK_PORT` defaults to `18790`; `WOLFPACK_SESSION_NAME` resolves the active Pi process to its relay endpoint. Read `taskEndpoint` from structured session creation/status and pass its opaque `{ relay, id }` unchanged. Never derive it from a name, terminal output or broker ID.
14
14
 
@@ -28,26 +28,35 @@ The `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`.
28
28
 
29
29
  ## history versus live state
30
30
 
31
- - Pi requests task-processing turns through its safe `deliverAs: "followUp"` queue. Pi session messages and tool calls/results preserve historical context. Non-waking receipts, parent ACKs, late-terminal facts and confirmed receiver-authored conversation echoes are archived as `pi-tasks-event-record` custom entries before transport ACK, without model insertion or wake. Received model-visible task events include the complete structured event in `pi-tasks-event` details, including results—not just a rendered summary.
31
+ - Pi requests task-processing turns through its safe `deliverAs: "followUp"` queue. Pi session messages and tool calls/results preserve historical context. Parent ACKs, late-terminal facts and confirmed receiver-authored conversation echoes are archived as `pi-tasks-event-record` custom entries before transport ACK, without model insertion or wake. Received model-visible task events include the complete structured event in `pi-tasks-event` details, including results—not just a rendered summary.
32
32
  - Active tasks, outbox intentions, individual ACK checkpoints, deduplication and delivery-blocked evidence are RAM-owned. Each endpoint has lower-only limits of 16,384 records/32MiB encoded state. Admission fails before exceeding those bounds; they are not a process-RSS guarantee.
33
33
  - `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.
34
+ - Delivery receipts are origin-local status. The origin records each one in its task log without presenting, archiving or forwarding it to the worker. Stages are ordered `receiver_recorded` < `pi_inserted` < `wake_accepted`, and a later stage implies the earlier ones. Per event and refresh, a receiver sends only the highest stage it reached, plus `pi_insertion` `blocked` when Pi did not record the insertion, so an assignment received, inserted and woken in one refresh costs one receipt. The `wake_accepted` record is written before Pi emits the wake, which keeps it ahead of any intent the woken model sends; it is sent only after the wake appears in Pi's entries, and an unobserved or throwing wake withdraws the never-sent record. `wake_requested` remains in `TaskDeliveryStage` and is accepted from 0.1.15 receivers (status shows wake acceptance pending) but is no longer emitted.
34
35
  - 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.
35
36
  - 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.
36
37
 
37
38
  ## memory-owned lifecycle
38
39
 
39
- `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.
40
+ `createConfiguredTaskCore()` owns its transport and `createTaskStore()` RAM state. Startup registers; background polling is one `setTimeout` chain that re-arms after each refresh: every 1s while a task is active, the outbox is pending, or a gate-enabled worker awaits an eligible assignment, otherwise every 5s. Successful send/message/done/ack/cancel tools, `turn_end`, agent end and agent settlement request an immediate single-flight refresh; requests made during a running refresh coalesce into one trailing refresh. `agent_task_wait` requests one refresh, then re-checks local state when the RAM store commits a change or after a backoff of 250ms doubling to 2s; it issues no relay requests of its own. Shutdown fences new work, aborts requests, drains active calls and discards state. Late callbacks cannot mutate a successor lifetime.
40
41
 
41
- Background refresh records protocol facts in RAM even while Pi is busy or has queued messages. A valid completion within the fetched receive page can become canonical before its notification is presented. Model-visible insertion and the separate follow-up wake still wait for idle Pi with no queued messages; visible relay mail remains replayable until the required structured insertion/wake evidence is present. Intake alone does not incorporate an assignment or authorize worker tools. Parent acknowledgment before terminal presentation retains the event but suppresses its redundant wake. Origin-authored conversations do not echo to the origin. Receiver-authored conversation copies remain canonical replicas, but local outbound intent identity makes their presentation archive-only; payload author claims are not provenance. Missing or unmatched intent identity leaves peer conversations actionable. This correlation is RAM-only and is never recovered from session history.
42
+ Background refresh records protocol facts in RAM even while Pi is busy or has queued messages. A valid completion within the fetched receive page can become canonical before its notification is presented. Model-visible insertion and the separate follow-up wake still wait for idle Pi with no queued messages; visible relay mail remains replayable until the required structured insertion/wake evidence is present. Intake alone does not incorporate an assignment or authorize worker tools. Parent acknowledgment before terminal presentation retains the event but suppresses its redundant wake. Origin-authored conversations do not echo to the origin. The origin's own view of other canonical events it creates, such as a completion canonicalized from a worker's raw intent, is queued in a RAM-only local presentation table instead of a self-addressed relay envelope. `receive()` merges those local items with relay mail by intake order. A held model-visible item blocks later visible items; non-visible items behind it are still archived and ACKed. A local item needs the same insertion and wake evidence before it completes, but records no relay delivery receipts. Receiver-authored conversation copies remain canonical replicas, but local outbound intent identity makes their presentation archive-only; payload author claims are not provenance. Missing or unmatched intent identity leaves peer conversations actionable. This correlation is RAM-only and is never recovered from session history.
42
43
 
43
- The normal 5s polling cadence and relay byte limits are unchanged. A single-flight background refresh shares one work scope through lease connection, ACK repair, timeout fan-out, receive and all inbox receipts: at most 56 scoped sends plus 8 rotating old-work sends, 48 ACK attempts and one initial receive page of up to 50 items, with a 2s overall deadline. Failed attempts consume the shared counts; the same send/ACK identity is not attempted twice in that scope. Separate 250ms retry/ACK-repair slices stop admitting another attempt, not an already admitted request: each admitted request gets the remaining background window. These are scheduling bounds, not throughput guarantees. Foreground send/message/done retain the configured transport request deadline (default 12s) and caller cancellation instead of inheriting the background cutoff. Any speculative old-work retry after a foreground send uses a finite 2s background window, starting only when that share is reached.
44
+ The adaptive 1s/5s polling cadence does not change relay byte limits. A single-flight background refresh runs explicit phases in one work scope: lease connection, then intake (timeout canonicalization and relay receive persist and canonicalize without sending), presentation (Pi insertion/wake and receipt records, still without sending), shipping (required timeout fan-out, intake fan-out and receipts in persist order), acknowledgement (ACKs for presented items, then raw ACK repair) and finally old-work retry. The shared counts are: at most 56 scoped sends plus 8 rotating old-work sends, 48 ACK attempts and receive pages of up to 50 items while fewer than 200 relay deliveries are fetched but unacknowledged, with a 2s overall deadline. Failed attempts consume the shared counts; the same send/ACK identity is not attempted twice in that scope. Separate 250ms retry/ACK-repair slices stop admitting another attempt, not an already admitted request: each admitted request gets the remaining background window. These are scheduling bounds, not throughput guarantees. Foreground send/message/done retain the configured transport request deadline (default 12s) and caller cancellation instead of inheriting the background cutoff. Any speculative old-work retry after a foreground send uses a finite 2s background window, starting only when that share is reached.
44
45
 
45
- Each refresh deliberately fetches only one initial page, even when `hasMore` is true or the ACK frontier advances. Extra-page saturation draining (#27) is deferred, not fixed or optimized; byte limits can make this page shorter than 50 items, and shared send/ACK counts can defer work within it. The normal refresh reserves initial protocol intake ahead of requested-ACK repair; page intake also precedes its own repair pass. Speculative old-work sends wait until all current-page intake and available presentation/evidence/ACK work completes or deliberately yields. Inbox delivery holds that gate across receive and nested fanout/receipt flushes, releasing it in finalization even on empty, held/busy or failed passes when budget remains. Direct core receive suppresses speculation during page materialization and retains its bounded after-intake retry opportunity; an inbox-owned gate remains closed until presentation yields. After required canonical fan-out acceptance, both new and replayed raw intents stage authorized ACK requests in the existing checkpoint rather than retrying inline. The shared after-intake repair pass uses the same ACK budget, admission slice and overall background deadline; remaining raw ACK work reports local deferral on budget/slice/cooldown exhaustion. Failed or pending required fan-out still stops intake at that delivery. Retryable/slow repair still reports failure and retains uncertain requested IDs, but cannot prevent that initial real receive. A failed repair yields before presentation. Ignored-abort ACK responses cannot later advance local authority. Held visible mail, missing insertion/wake evidence, backpressure or exhaustion yields without reading ahead. A full held prefix can still prevent intake of a newer intent beyond that page; general backlog scanning is not provided.
46
+ Intake first re-drives tracked, unacknowledged relay deliveries from the local RAM inbox in cursor order, then fetches after the highest tracked cursor and follows `hasMore` while the pending set has room. At 200 fetched-but-unacknowledged deliveries fetching pauses until ACKs drain it; nothing is skipped or dropped. Each delivery is tracked and persisted on its own before fan-out, so an abort mid-page loses nothing and the next fetch resumes after the last tracked cursor. Byte limits can make a page shorter than 50 items, and shared send/ACK counts can defer work. When the ship phase accepts a raw intent's required canonical fan-out, the intent stages an authorized ACK request in the existing checkpoint; the acknowledge phase's repair pass sends it, and a staged delivery is not re-driven. Failed or pending required fan-out withholds only that intent's ACK and no longer stops intake of later deliveries. A presented relay item is ACKed only after the ship phase accepted its receipts or found them blocked for good (reported as outbox degradation); a failed receipt withholds only that item's ACK, and presentation, shipping and ACKs of later items continue. Receipts to one target still leave in creation order, so a failed receipt also defers younger receipts to the same origin. Because receipts ship before ACKs, an ACK never precedes the receipt that justifies it. The repair pass uses the same ACK budget, admission slice and overall background deadline; remaining raw ACK work reports local deferral on budget/slice/cooldown exhaustion. Presentation stops at the remaining ACK budget. The first fan-out, receipt, presentation, repair or deferral failure is collected and reported after the pass, so it no longer holds back items intake already produced. Core operations in a phased pass never start old-work retry or ACK repair on their own; only the final phase retries old work, after required sends and ACKs, even on empty, held/busy or failed passes when budget remains. Ignored-abort ACK responses cannot later advance local authority. A held visible event stops presentation of later visible events but not archive and ACK of later non-visible events; an ACK budget exhaustion stops the pass. An intent whose required fan-out is quarantined is never ACKed and keeps one pending slot. General backlog scanning beyond the pending bound is not provided.
46
47
 
47
- Scoped operations attempt their own immutable IDs and cannot report acceptance for omitted or partial fan-out. Concurrent same-ID sends join existing delivery rather than retransmitting. Pending retry IDs rotate incrementally in RAM on attempts/actual skips, including failures and cooldown skips, without copying payloads or rotating an unattempted suffix past the retry deadline. A foreground packet's capacity refusal does not suppress smaller old packets to the same target; same-cycle target suppression starts only when speculative retry itself encounters capacity. Known capacity defers that envelope/ACK for at least 5s (or the validated longer `retryAfterMs`), without a retry timer. `TASK_DELIVERY_DEFERRED` is a retryable local budget/cooldown outcome, not relay acceptance or permanent quarantine. Raw intent replays retain their canonical fan-out reservation and are not ACKed while required sends remain pending; quarantined required sends report their blocked outcome instead of false acceptance. The exported `TaskStore` contract requires indexed lookup, fair rotation and cooldown capabilities, supplied by `createTaskStore()`; there is no legacy snapshot/fixed-prefix fallback. `TaskCore.receive()` retains its array return and the documented transport seam is unchanged.
48
+ Scoped operations attempt their own immutable IDs and cannot report acceptance for omitted or partial fan-out. Concurrent same-ID sends join existing delivery rather than retransmitting. Pending retry IDs rotate incrementally in RAM on attempts/actual skips, including failures and cooldown skips, without copying payloads or rotating an unattempted suffix past the retry deadline. A foreground packet's capacity refusal does not suppress smaller old packets to the same target; same-cycle target suppression starts only when speculative retry itself encounters capacity. Mail to one target leaves in creation order: an envelope is sent only after every older pending envelope to the same target is accepted, and a younger send waits for an older send already in flight. While the oldest envelope is cooling down or out of budget, younger mail to that target reports `TASK_DELIVERY_DEFERRED` (with `blockedBy`); a quarantined envelope leaves the queue and blocks nothing, and other targets are unaffected. Retry selection offers only each target's oldest pending envelope, and a reported send first offers older mail to its target from the bounded old-work share. Known capacity defers that envelope/ACK for at least 5s (or the validated longer `retryAfterMs`), without a retry timer. `TASK_DELIVERY_DEFERRED` is a retryable local budget/cooldown outcome, not relay acceptance or permanent quarantine. Raw intent replays retain their canonical fan-out reservation and are not ACKed while required sends remain pending; quarantined required sends report their blocked outcome instead of false acceptance. The exported `TaskStore` contract requires indexed lookup, fair rotation, cooldown, per-target FIFO selection (`pendingOutboxAhead`), withdrawal of a never-sent reservation (`discardUnsentOperation`), the RAM local presentation queue, post-commit `onChange` notification, the indexed `getQuarantine(envelopeId)` and `hasActiveTasks()` lookups and `setTarget(taskId, target)`, supplied by `createTaskStore()`; custom `TaskStore`/`TaskCore` implementations written against 0.1.15 must add these members (`TaskCore` also gains `hasPendingWork`, `onChange`, `completeLocalPresentation` and the phased-pass members `intakeInbox`, `shipRequired` and `repairAcknowledgements`, and its `WakeRequest` gains `discard()`; `createTaskWorkScope(signal, "phased")` makes a scope whose evidence and fan-out sends are queued for `shipRequired`, and `deliverTaskInbox` requires one); there is no legacy snapshot/fixed-prefix fallback. `TaskCore.receive()` returns an ordered array of `{ kind: "relay", delivery, order }` and `{ kind: "local", event, order }` items; a consumer ACKs relay items and completes local items with `completeLocalPresentation(eventId)`. The documented transport seam is unchanged, and origin-targeted canonical envelopes from older peers are still accepted.
49
+
50
+ `createTaskStore()` keeps each row as immutable encoded text and caches its parsed value on first read; events are indexed by task and active tasks are tracked as a set, and both indexes follow transaction rollback and `clear()`. Stored values are deep-frozen and shared between reads: outbox and quarantine records, task events, payloads and endpoints throw a `TypeError` on a strict-mode write. Task snapshots and pending presentations are fresh top-level objects around those frozen values, so a write to their own fields is lost. Neither reaches the store. The store's own read-modify-write of the ACK checkpoint works on a copy. `createTaskCore({ timers })` takes an optional `TaskTimers` (`now`, `elapsed`, `timeout`) for the delivery budget: the 2s deadline, the retry/ACK-repair slices and capacity cooldowns. It defaults to system time and is separate from `clock`, which stamps task records; tests use it to run deadline and cooldown cases on virtual time.
48
51
 
49
52
  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.
50
53
 
54
+ An origin can move an active task off a lost target with the optional `TaskCore.reassignTask(taskId)`, which `createTaskCore()` provides. It requires a quarantined send to the current target whose relay refusal was `TARGET_NOT_REGISTERED`; otherwise it fails with non-retryable `REASSIGNMENT_REFUSED` (`NOT_ORIGIN` for a non-origin caller). It resolves the lost endpoint's reference through the relay and refuses when it resolves to itself; the loss and the target are re-checked inside the commit, so one loss allows one reassignment even when calls race. It then sets the task's target, appends a `task.reassigned` event (payload `{ task, previousTarget }`), presents it locally at the origin and sends the successor an assignment envelope carrying `{ task, event, history }`. History that would exceed `MAX_RELAY_PAYLOAD_BYTES` keeps the newest events that fit and sets `historyTruncated: true`; a task too large even without history fails with `PAYLOAD_TOO_LARGE`. The receiver validates the carried history against the task and records it like an original assignment: it sends a `receiver_recorded` receipt, presents a `## task assignment (reassigned)` message and opens the worker gate for that task. A known task follows the reassignment, and carried events it already holds are skipped. A 0.1.15 receiver rejects `task.reassigned` as `INVALID_ASSIGNMENT`.
55
+
56
+ The default extension does not call `reassignTask` yet. A current Wolfpack server issues a fresh random endpoint id on every rebind and answers `resolve` for a retired id with `TARGET_NOT_REGISTERED`, so the origin can detect the loss but cannot discover the successor; the client also rejects a `resolve` result whose id differs from the reference. Automatic reassignment needs the server to resolve a retired id (or a stable session-scoped reference that survives restart) to its live successor, and then a client change to accept the differing id.
57
+
58
+ Error codes have one internal registry (`src/task-errors.ts`) that gives each code a class, a default message and explicit retry, rebind, relay-loss and blocked-delivery flags; a per-throw `retryable` still wins. Every failure of a `createVolatileTaskSession` relay request carries the relay operation in `details.operation`, and failures built from relay responses use the registry message for their code (server-supplied text was never shown); codes, `retryable` and other details are unchanged, and nothing new goes on the wire. The status line names a failed relay operation as `tasks: <class> (<CODE>) on <operation>`, for example `tasks: capacity (RELAY_CAPACITY) on send`; an unregistered code has class `error`. Relay loss, a missing compatible profile and required authentication keep their dedicated labels, and failures without a named operation keep `tasks: outbox degraded` or `tasks: relay unavailable`. Event handling (model-visible, terminal and its status, conversational, origin-local evidence, accepted as a receiver intent) comes from one internal `TASK_EVENT_TYPES` table in `src/task-protocol.ts`; unknown event types are still rejected.
59
+
51
60
  `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.
52
61
 
53
62
  Full immutable content, including the original creation timestamp, is reused for retries. Destination-confirmed acceptance is required. Conflicting content, exhausted delivery and reset cannot become successful acceptance. Individual ACKs preserve sparse bigint cursors and cannot skip earlier pending mail. Bounds/deadlines apply through complete response bodies, including noncooperative injected transports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Memory-only endpoint-owned Pi agent delegation through Wolfpack relay v2.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -1,16 +1,25 @@
1
- export async function abortableSleep(milliseconds: number, signal: AbortSignal | undefined, cancellationError: () => Error): Promise<void> {
1
+ /** Sleep until the timeout, an optional early wake, or abort; every exit path releases the timer and both subscriptions. */
2
+ export async function abortableSleep(milliseconds: number, signal: AbortSignal | undefined, cancellationError: () => Error, subscribeWake?: (wake: () => void) => () => void): Promise<void> {
2
3
  if (signal?.aborted) throw cancellationError();
3
4
  await new Promise<void>((resolve, reject) => {
4
- const removeAbortListener = (): void => signal?.removeEventListener("abort", abort);
5
+ let unsubscribeWake: (() => void) | undefined;
6
+ const release = (): void => {
7
+ clearTimeout(timeout);
8
+ signal?.removeEventListener("abort", abort);
9
+ unsubscribeWake?.();
10
+ };
5
11
  const timeout = setTimeout(() => {
6
- removeAbortListener();
12
+ release();
7
13
  resolve();
8
14
  }, milliseconds);
9
15
  const abort = (): void => {
10
- clearTimeout(timeout);
11
- removeAbortListener();
16
+ release();
12
17
  reject(cancellationError());
13
18
  };
14
19
  signal?.addEventListener("abort", abort, { once: true });
20
+ unsubscribeWake = subscribeWake?.(() => {
21
+ release();
22
+ resolve();
23
+ });
15
24
  });
16
25
  }
@@ -50,10 +50,14 @@ export async function createConfiguredTaskCore(options: ConfiguredTaskCoreOption
50
50
  const result: unknown = value.apply(core, args);
51
51
  if (key === "recordWakeRequest") {
52
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
- } };
53
+ return {
54
+ flush: (signal?: AbortSignal): Promise<void> => {
55
+ if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task session is closed", { retryable: false });
56
+ return track(request.flush(signal));
57
+ },
58
+ // A closed session drops its store; there is nothing left to withdraw.
59
+ discard: () => { if (!closed) request.discard(); },
60
+ };
57
61
  }
58
62
  return result instanceof Promise ? track(result) : result;
59
63
  } });
package/src/extension.ts CHANGED
@@ -5,8 +5,9 @@ import { Type } from "typebox";
5
5
 
6
6
  import { abortableSleep } from "./abortable-sleep";
7
7
  import { createTaskWorkScope } from "./task-work-scope";
8
- import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
9
- import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
8
+ import { deliverTaskInbox, taskEntryEvidence, type TaskEntryEvidence } from "./task-inbox";
9
+ import { RELAY_LOSS_CODES, taskErrorClass } from "./task-errors";
10
+ import { TASK_EVENT_TYPES, TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
10
11
  import { createConfiguredTaskCore } from "./configured-task-core";
11
12
  import type { OwnedTaskCore } from "./configured-task-core";
12
13
  import type { SubmitIntentInput, SubmitIntentOutcome, TaskCore } from "./task-core";
@@ -15,12 +16,17 @@ import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
15
16
  const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
16
17
  const MIN_TASK_TIMEOUT_MS = 1_000;
17
18
  const MAX_TASK_TIMEOUT_MS = 86_400_000;
18
- const BACKGROUND_POLL_MS = 5_000;
19
+ /** Background cadence while work is pending (active task, pending outbox, or a worker awaiting assignment). */
20
+ export const ACTIVE_POLL_MS = 1_000;
21
+ /** Background cadence otherwise. */
22
+ export const IDLE_POLL_MS = 5_000;
19
23
  const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
24
+ /** Events that assign a task to their target: the original assignment and a reassignment to a lost target's successor. */
25
+ const ASSIGNMENT_EVENTS: ReadonlySet<string> = new Set(["task.created", "task.reassigned"]);
20
26
  const RELAY_STATE_LOST_CODE = "RELAY_STATE_LOST";
21
27
  const RELAY_LOSS_CUSTOM_TYPE = "pi-tasks-relay-loss";
22
- const RELAY_LOSS_CODES = new Set(["RELAY_RESET", "RELAY_REBIND_REQUIRED"]);
23
- const WAIT_POLL_MS = 250;
28
+ const WAIT_BACKOFF_INITIAL_MS = 250;
29
+ const WAIT_BACKOFF_MAX_MS = 2_000;
24
30
  const SUMMARY_MAX_CHARS = 1_200;
25
31
  const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
26
32
  const COORDINATOR_ONLY_TASK_TOOLS = new Set(["agent_task_send", "agent_task_cancel", "agent_task_ack"]);
@@ -101,7 +107,7 @@ export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): Conf
101
107
  /** Registers endpoint-owned tools using the memory-owned Wolfpack transport by default. */
102
108
  export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = defaultCoreFactory): void {
103
109
  let inboxContext: ExtensionContext | undefined;
104
- let backgroundTimer: ReturnType<typeof setInterval> | undefined;
110
+ let backgroundTimer: ReturnType<typeof setTimeout> | undefined;
105
111
  let lifecycleEpoch = 0;
106
112
  const closingTaskIds = new Set<string>();
107
113
  const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
@@ -114,8 +120,8 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
114
120
  const epoch = lifecycleEpoch;
115
121
  const isCurrent = (): boolean => inboxContext === context && lifecycleEpoch === epoch;
116
122
  if (!context) return undefined;
117
- const work = createTaskWorkScope(signal);
118
- work.retryAllowed = false; // Reserve initial intake ahead of speculative backlog work and ACK repair.
123
+ // Phased: intake runs ahead of speculative backlog work and ACK repair.
124
+ const work = createTaskWorkScope(signal, "phased");
119
125
  signal = work.signal;
120
126
  const activeCore = await configuredCore(signal);
121
127
  if (!isCurrent()) return undefined;
@@ -141,9 +147,23 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
141
147
  const deliveries = await activeCore.receive(receiveSignal, receiveWork);
142
148
  return isCurrent() ? deliveries : [];
143
149
  },
150
+ async intakeInbox(intakeSignal, intakeWork) {
151
+ if (!isCurrent()) return [];
152
+ const items = await activeCore.intakeInbox(intakeSignal, intakeWork);
153
+ return isCurrent() ? items : [];
154
+ },
155
+ async shipRequired(shipSignal, shipWork) {
156
+ if (isCurrent()) await activeCore.shipRequired(shipSignal, shipWork);
157
+ },
158
+ async repairAcknowledgements(repairSignal, repairWork) {
159
+ if (isCurrent()) await activeCore.repairAcknowledgements(repairSignal, repairWork);
160
+ },
144
161
  async acknowledgeRelayDelivery(cursor, acknowledgementSignal, acknowledgementWork) {
145
162
  if (isCurrent()) await activeCore.acknowledgeRelayDelivery(cursor, acknowledgementSignal, acknowledgementWork);
146
163
  },
164
+ completeLocalPresentation(eventId) {
165
+ if (isCurrent()) activeCore.completeLocalPresentation(eventId);
166
+ },
147
167
  async recordInsertion(input, insertionSignal, insertionWork) {
148
168
  if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal, insertionWork);
149
169
  },
@@ -156,10 +176,14 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
156
176
  recordWakeRequest(input, wakeWork) {
157
177
  if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
158
178
  const request = activeCore.recordWakeRequest(input, wakeWork);
159
- return { flush: async (flushSignal?: AbortSignal): Promise<void> => {
160
- if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
161
- await request.flush(flushSignal);
162
- } };
179
+ return {
180
+ flush: async (flushSignal?: AbortSignal): Promise<void> => {
181
+ if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
182
+ await request.flush(flushSignal);
183
+ },
184
+ // A replaced lifecycle discards its own state; there is nothing left to withdraw.
185
+ discard() { if (isCurrent()) request.discard(); },
186
+ };
163
187
  },
164
188
  };
165
189
  const inboxOutboxError = await deliverTaskInbox({
@@ -172,13 +196,57 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
172
196
  }, signal, work);
173
197
  return outboxError ?? inboxOutboxError;
174
198
  });
199
+ const cancelBackgroundRefresh = (): void => {
200
+ if (backgroundTimer) clearTimeout(backgroundTimer);
201
+ backgroundTimer = undefined;
202
+ };
203
+ /** Keep exactly one pending background timer; each lifecycle refresh re-arms it with the next cadence. */
204
+ const scheduleBackgroundRefresh = (epoch: number, delayMs: number): void => {
205
+ cancelBackgroundRefresh();
206
+ backgroundTimer = setTimeout(() => {
207
+ backgroundTimer = undefined;
208
+ if (inboxContext && lifecycleEpoch === epoch) void requestRefresh();
209
+ }, delayMs);
210
+ };
211
+ // Active cadence while a task is active, the outbox is pending, or a gate-enabled worker lacks an eligible assignment.
212
+ // An eligible assignment implies an active task, so the worker clause together with the first reduces to `workerGateEnabled`.
213
+ const nextPollDelay = (activeCore: TaskCore | undefined): number => activeCore !== undefined && (workerGateEnabled || activeCore.hasPendingWork()) ? ACTIVE_POLL_MS : IDLE_POLL_MS;
175
214
  const startBackgroundRefresh = (context: ExtensionContext, epoch: number): void => {
176
215
  inboxContext = context;
177
- if (backgroundTimer) clearInterval(backgroundTimer);
178
- backgroundTimer = setInterval(() => {
179
- const currentContext = inboxContext;
180
- if (currentContext) void refreshLifecycle(currentContext, epoch);
181
- }, BACKGROUND_POLL_MS);
216
+ scheduleBackgroundRefresh(epoch, IDLE_POLL_MS);
217
+ };
218
+ let lifecycleRefresh: Promise<void> | undefined;
219
+ let trailingRefresh = false;
220
+ /** Immediate single-flight lifecycle refresh; requests made while one runs coalesce into one trailing run. */
221
+ const requestRefresh = (): Promise<void> => {
222
+ if (lifecycleRefresh) {
223
+ trailingRefresh = true;
224
+ return lifecycleRefresh;
225
+ }
226
+ const current = (async (): Promise<void> => {
227
+ do {
228
+ trailingRefresh = false;
229
+ const context = inboxContext;
230
+ if (!context) return;
231
+ try {
232
+ await refreshLifecycle(context, lifecycleEpoch);
233
+ } catch (error) {
234
+ // Callers fire and forget, so a failure escaping refreshLifecycle (e.g. a throwing status UI) must not reject.
235
+ console.error("pi-tasks: lifecycle refresh failed", error);
236
+ if (inboxContext === context) {
237
+ try {
238
+ context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", `tasks: refresh failed: ${error instanceof Error ? error.message : String(error)}`));
239
+ } catch {
240
+ // Status is best effort; the failure is already logged above.
241
+ }
242
+ }
243
+ }
244
+ } while (trailingRefresh);
245
+ })().finally(() => {
246
+ if (lifecycleRefresh === current) lifecycleRefresh = undefined;
247
+ });
248
+ lifecycleRefresh = current;
249
+ return current;
182
250
  };
183
251
  const announceFreshBinding = (context: ExtensionContext): void => {
184
252
  if (pendingRelayLossCount === undefined) return;
@@ -194,8 +262,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
194
262
  const recoveryEpoch = ++lifecycleEpoch;
195
263
  const activeTasks = activeCore?.listTasks().filter((task) => task.status === "active") ?? [];
196
264
  inboxContext = undefined;
197
- if (backgroundTimer) clearInterval(backgroundTimer);
198
- backgroundTimer = undefined;
265
+ cancelBackgroundRefresh();
199
266
  await ownedLoader.close();
200
267
  if (recoveryEpoch !== lifecycleEpoch) return;
201
268
  for (const task of activeTasks) {
@@ -241,6 +308,19 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
241
308
  } catch (error) {
242
309
  if (isCurrent() && isRelayLoss(error) && await recoverRelayLoss(context, epoch, activeCore)) return;
243
310
  if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
311
+ } finally {
312
+ if (isCurrent()) {
313
+ // The chain must outlive a failed cadence evaluation (e.g. an unreadable task snapshot): err toward polling and surface the fault.
314
+ let delay = ACTIVE_POLL_MS;
315
+ let delayError: unknown;
316
+ try {
317
+ delay = nextPollDelay(activeCore);
318
+ } catch (error) {
319
+ delayError = error;
320
+ }
321
+ scheduleBackgroundRefresh(epoch, delay); // Armed before the status write, which may itself throw.
322
+ if (delayError !== undefined) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", `tasks: local task state unreadable: ${delayError instanceof Error ? delayError.message : String(delayError)}`));
323
+ }
244
324
  }
245
325
  };
246
326
 
@@ -249,7 +329,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
249
329
  if (COORDINATOR_ONLY_TASK_TOOLS.has(event.toolName)) return { block: true, reason: WORKER_COORDINATION_FORBIDDEN_CODE };
250
330
  try {
251
331
  const activeCore = await configuredCore(context.signal);
252
- const assignments = assignedWorkerTasks(activeCore, context.sessionManager.getEntries());
332
+ const assignments = assignedWorkerTasks(activeCore, taskEntryEvidence(context.sessionManager));
253
333
  const taskId = inputTaskId(event.input);
254
334
  if (event.toolName === "agent_task_done") {
255
335
  const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
@@ -276,25 +356,25 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
276
356
  await ownedLoader?.start();
277
357
  if (epoch !== lifecycleEpoch) return;
278
358
  startBackgroundRefresh(context, epoch);
279
- await refreshLifecycle(context, epoch);
359
+ await requestRefresh();
280
360
  });
281
361
  pi.on("agent_end", async (_event, context) => {
282
362
  if (!inboxContext) return;
283
- const epoch = lifecycleEpoch;
284
363
  inboxContext = context;
285
- await refreshLifecycle(context, epoch);
364
+ await requestRefresh();
286
365
  });
287
366
  pi.on("agent_settled", async (_event, context) => {
288
367
  if (!inboxContext) return;
289
- const epoch = lifecycleEpoch;
290
368
  inboxContext = context;
291
- await refreshLifecycle(context, epoch);
369
+ await requestRefresh();
370
+ });
371
+ pi.on("turn_end", async () => {
372
+ void requestRefresh();
292
373
  });
293
374
  pi.on("session_shutdown", async () => {
294
375
  closingTaskIds.clear();
295
376
  lifecycleEpoch += 1;
296
- if (backgroundTimer) clearInterval(backgroundTimer);
297
- backgroundTimer = undefined;
377
+ cancelBackgroundRefresh();
298
378
  inboxContext = undefined;
299
379
  pendingRelayLossCount = undefined;
300
380
  await ownedLoader?.close();
@@ -305,6 +385,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
305
385
  async execute(_id, params, signal) {
306
386
  try {
307
387
  const sent = await (await configuredCore(signal)).createTask({ target: params.to, task: params.task, timeoutMs: params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS }, signal);
388
+ void requestRefresh();
308
389
  return toolResult(sent, `## task accepted\n- task: \`${sent.taskId}\`\n- target: \`${params.to.relay}/${params.to.id}\`\n- delivery: relay acceptance only`);
309
390
  } catch (error) { return taskError(error); }
310
391
  },
@@ -327,14 +408,17 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
327
408
  async execute(_id, params, signal, onUpdate) {
328
409
  const deadline = Date.now() + (params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS);
329
410
  try {
330
- for (;;) {
331
- await refreshInbox(signal);
332
- const task = (await configuredCore(signal)).getTask(params.taskId);
411
+ // Fetching belongs to the background cadence; the wait re-checks local state on store changes or backoff.
412
+ void requestRefresh();
413
+ for (let backoff = WAIT_BACKOFF_INITIAL_MS; ; backoff = Math.min(backoff * 2, WAIT_BACKOFF_MAX_MS)) {
414
+ // Resolve the core each round so a relay-loss replacement is re-subscribed.
415
+ const activeCore = await configuredCore(signal);
416
+ const task = activeCore.getTask(params.taskId);
333
417
  if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
334
418
  if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
335
419
  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`);
336
420
  onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
337
- await abortableSleep(Math.max(1, Math.min(WAIT_POLL_MS, deadline - Date.now())), signal, () => new Error("task wait was cancelled"));
421
+ await abortableSleep(Math.max(1, Math.min(backoff, deadline - Date.now())), signal, () => new Error("task wait was cancelled"), (wake) => activeCore.onChange(wake));
338
422
  }
339
423
  } catch (error) { return taskError(error); }
340
424
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
@@ -353,7 +437,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
353
437
  });
354
438
  pi.registerTool({
355
439
  name: "agent_task_message", label: "Message Agent Task", description: "Record a message intent in this lifetime before relay submission.", parameters: MessageParams,
356
- 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); } },
440
+ async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.type}`, payload: { message: params.message } }, signal); void requestRefresh(); return toolResult({ taskId: params.taskId }, `## task ${params.type}\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
357
441
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
358
442
  });
359
443
  pi.registerTool({
@@ -363,6 +447,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
363
447
  const activeCore = await configuredCore(signal);
364
448
  try {
365
449
  await activeCore.submitIntent({ taskId: params.taskId, type: "task.cancelled", payload: {} }, signal);
450
+ void requestRefresh();
366
451
  return toolResult({ taskId: params.taskId }, `## task cancellation requested\n- task: \`${params.taskId}\``);
367
452
  } catch (error) {
368
453
  return blockedCancellationResult(activeCore, params.taskId, error) ?? taskError(error);
@@ -373,7 +458,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
373
458
  });
374
459
  pi.registerTool({
375
460
  name: "agent_task_ack", label: "Acknowledge Agent Task", description: "Record parent acknowledgement as an origin-owned logical event.", parameters: TaskIdParams,
376
- async execute(_id, params, signal) { try { await (await configuredCore(signal)).acknowledgeParent(params.taskId, signal); return toolResult({ taskId: params.taskId }, `## task acknowledged\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
461
+ async execute(_id, params, signal) { try { await (await configuredCore(signal)).acknowledgeParent(params.taskId, signal); void requestRefresh(); return toolResult({ taskId: params.taskId }, `## task acknowledged\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
377
462
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
378
463
  });
379
464
  pi.registerTool({
@@ -389,6 +474,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
389
474
  } catch (error) {
390
475
  return blockedDoneResult(activeCore, params.taskId, params.status, error) ?? taskError(error);
391
476
  }
477
+ void requestRefresh();
392
478
  const task = activeCore.getTask(params.taskId);
393
479
  if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
394
480
  if (submission?.authority === "origin" && submission.canonicalEvent.type === "task.late_terminal") {
@@ -438,7 +524,8 @@ function taskDeliveryEvidence(task: TaskSnapshot): TaskDeliveryEvidence {
438
524
  let receiverReceipt: TaskDeliveryEvidence["receiverReceipt"] = "not_confirmed";
439
525
  let piInsertion: TaskDeliveryEvidence["piInsertion"] = "not_confirmed";
440
526
  let wakeAcceptance: TaskDeliveryEvidence["wakeAcceptance"] = "not_confirmed";
441
- const assignmentEventId = task.events.find((event) => event.type === "task.created")?.eventId;
527
+ // After a reassignment, evidence describes the latest assignment.
528
+ const assignmentEventId = task.events.findLast((event) => ASSIGNMENT_EVENTS.has(event.type))?.eventId;
442
529
  for (const event of task.events) {
443
530
  if (event.type !== "task.delivery_receipt" || event.payload.eventId !== assignmentEventId) continue;
444
531
  if (event.payload.stage === TaskDeliveryStage.receiverRecorded) receiverReceipt = "confirmed";
@@ -447,8 +534,13 @@ function taskDeliveryEvidence(task: TaskSnapshot): TaskDeliveryEvidence {
447
534
  receiverReceipt = "confirmed";
448
535
  piInsertion = "confirmed";
449
536
  }
537
+ // Legacy v0.1.15 receivers still send wake_requested; current receivers send only the highest stage.
450
538
  if (event.payload.stage === TaskDeliveryStage.wakeRequested) wakeAcceptance = "pending";
451
- if (event.payload.stage === TaskDeliveryStage.wakeAccepted) wakeAcceptance = "confirmed";
539
+ if (event.payload.stage === TaskDeliveryStage.wakeAccepted) {
540
+ receiverReceipt = "confirmed";
541
+ piInsertion = "confirmed";
542
+ wakeAcceptance = "confirmed";
543
+ }
452
544
  }
453
545
  return { receiverReceipt, piInsertion, wakeAcceptance, modelExecution: "not_evidenced" };
454
546
  }
@@ -508,11 +600,17 @@ function blockedDeliveryWarning(error: TaskOutboxDeliveryError, target: TaskEndp
508
600
  return { code: error.code, message: error.message, retryable: false, delivery: "blocked", target, details };
509
601
  }
510
602
 
511
- function outboxFailureStatus(error: unknown): string {
603
+ /**
604
+ * Status-line text for a failed refresh. Actionable relay-state labels come first; otherwise
605
+ * `tasks: <class> (<CODE>) on <operation>` when the failed relay operation is known.
606
+ */
607
+ export function outboxFailureStatus(error: unknown): string {
512
608
  if (error instanceof TaskProtocolError) {
513
609
  if (RELAY_LOSS_CODES.has(error.code)) return "tasks: relay state lost; rebinding automatically";
514
610
  if (error.code === "RELAY_PROFILE_REQUIRED") return "tasks: compatible memory-owned relay required";
515
611
  if (error.code === "RELAY_AUTH_REQUIRED") return "tasks: Wolfpack authentication required";
612
+ const operation = error.details?.operation;
613
+ if (typeof operation === "string") return `tasks: ${taskErrorClass(error.code)} (${error.code}) on ${operation}`;
516
614
  }
517
615
  return error instanceof TaskOutboxDeliveryError && isBlockedOutboxDeliveryCode(error.code) ? "tasks: outbox degraded" : "tasks: relay unavailable";
518
616
  }
@@ -522,26 +620,25 @@ function isRelayLoss(error: unknown): error is TaskProtocolError {
522
620
  }
523
621
 
524
622
  function terminal(status: string): boolean {
525
- return ["completed", "failed", "cancelled", "timed_out"].includes(status);
623
+ return Object.values(TASK_EVENT_TYPES).some(type => type.status === status);
526
624
  }
527
625
 
528
626
  function receiverAssignment(core: TaskCore, task: TaskSnapshot, context: Pick<ExtensionContext, "sessionManager">): string {
529
627
  if (task.status !== "active" || sameEndpoint(task.origin, core.endpoint) || !sameEndpoint(task.target, core.endpoint)) return "";
530
- const created = task.events.find((event) => event.type === "task.created");
531
- if (!created || !incorporatedTaskEvents(context.sessionManager.getEntries()).some((evidence) => evidence.taskId === task.taskId && evidence.eventId === created.eventId)) return "";
628
+ const incorporated = taskEntryEvidence(context.sessionManager).incorporated.get(task.taskId);
629
+ if (!task.events.some((event) => ASSIGNMENT_EVENTS.has(event.type) && sameEndpoint(event.target, core.endpoint) && incorporated?.has(event.eventId) === true)) return "";
532
630
  return `\n\n## task assignment\n${task.task}`;
533
631
  }
534
632
 
535
- function assignedWorkerTasks(core: TaskCore, entries: readonly unknown[]): readonly TaskSnapshot[] {
536
- const assignments = new Map<string, TaskSnapshot>();
537
- for (const evidence of incorporatedTaskEvents(entries)) {
538
- const task = core.getTask(evidence.taskId);
633
+ /** Session evidence is memoized; tasks are read fresh on every call so the gate follows current task state. */
634
+ function assignedWorkerTasks(core: TaskCore, evidence: TaskEntryEvidence): readonly TaskSnapshot[] {
635
+ const assignments: TaskSnapshot[] = [];
636
+ for (const [taskId, eventIds] of evidence.incorporated) {
637
+ const task = core.getTask(taskId);
539
638
  if (!task || !sameEndpoint(task.target, core.endpoint)) continue;
540
- const created = task.events.find((event) => event.eventId === evidence.eventId);
541
- if (!created || created.type !== "task.created" || !sameEndpoint(created.target, core.endpoint) || !sameEndpoint(created.source, task.origin)) continue;
542
- assignments.set(task.taskId, task);
639
+ if (task.events.some((event) => eventIds.has(event.eventId) && ASSIGNMENT_EVENTS.has(event.type) && sameEndpoint(event.target, core.endpoint) && sameEndpoint(event.source, task.origin))) assignments.push(task);
543
640
  }
544
- return [...assignments.values()];
641
+ return assignments;
545
642
  }
546
643
 
547
644
  function eligibleWorkerTask(task: TaskSnapshot, closingTaskIds: ReadonlySet<string>): boolean {
package/src/index.ts CHANGED
@@ -30,5 +30,6 @@ export type {
30
30
  TaskRecord,
31
31
  TaskRelay,
32
32
  } from "./task-protocol";
33
- export type { SubmitIntentOutcome, TaskCore, TaskCoreOptions } from "./task-core";
34
- export type { TaskStore, TaskStoreOptions } from "./task-store";
33
+ export type { SubmitIntentOutcome, TaskCore, TaskCoreOptions, TaskInboxItem } from "./task-core";
34
+ export type { PendingPresentation, PresentationRecord, TaskStore, TaskStoreOptions } from "./task-store";
35
+ export type { TaskTimers } from "./task-work-scope";